diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLDependencyManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLDependencyManager.java new file mode 100644 index 00000000000..dea1c0b83af --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLDependencyManager.java @@ -0,0 +1,65 @@ +/* + * 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.DownloadProvider; +import org.jetbrains.annotations.NotNullByDefault; + +/// Provides HMCL-specific game builders for an HMCL game repository. +@NotNullByDefault +public class HMCLDependencyManager extends DefaultDependencyManager { + /// 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 HMCLDependencyManager( + HMCLGameRepository repository, + DownloadProvider downloadProvider, + DefaultCacheRepository cacheRepository) { + super(repository, downloadProvider, cacheRepository); + } + + /// {@inheritDoc} + @Override + public HMCLGameRepository getGameRepository() { + return (HMCLGameRepository) super.getGameRepository(); + } + + /// {@inheritDoc} + @Override + public HMCLGameBuilder newGameBuilder(GameInstanceID instanceId) { + GameInstanceManifest initialManifest = new GameInstanceManifest(instanceId); + DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, null); + return new HMCLGameBuilder(this, instanceId, null, draft, initialManifest); + } + + /// {@inheritDoc} + @Override + public HMCLGameBuilder newGameBuilder(GameInstance instance) { + validateGameInstance(instance); + HMCLGameInstance updateTarget = (HMCLGameInstance) instance; + + GameInstanceManifest initialManifest = new GameInstanceManifest(updateTarget.getId()); + DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, updateTarget); + return new HMCLGameBuilder( + this, updateTarget.getId(), updateTarget, draft, initialManifest); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameBuilder.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameBuilder.java new file mode 100644 index 00000000000..7e259e4f4a1 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameBuilder.java @@ -0,0 +1,53 @@ +/* + * 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.DefaultGameBuilder; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +/// Builds game instances and applies HMCL-specific post-installation settings. +@NotNullByDefault +public class HMCLGameBuilder extends DefaultGameBuilder { + /// Creates a builder around an HMCL target already reserved by its dependency manager. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param instanceId the id of the reserved instance + /// @param updateTarget the exact update target retained by the draft, or `null` for install + /// @param draft the open draft containing `initialManifest` + /// @param initialManifest the empty target manifest retained by `draft` + HMCLGameBuilder( + HMCLDependencyManager dependencyManager, + GameInstanceID instanceId, + @Nullable HMCLGameInstance updateTarget, + DefaultGameRepositoryDraft draft, + GameInstanceManifest initialManifest) { + super(dependencyManager, instanceId, updateTarget, draft, initialManifest); + } + + /// {@inheritDoc} + /// + /// When isolation was enabled, persists the corresponding HMCL instance setting so subsequent + /// launches use the instance root. A read-only instance retains its existing setting. + @Override + protected void onInstanceCommitted(DefaultGameInstance instance) { + if (isolationEnabled) { + ((HMCLGameInstance) instance).enableIsolation(); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java index a592026c2e3..7412424232c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -189,28 +189,6 @@ public GameSettings.Effective getEffectiveSettings() { 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 @@ -233,6 +211,23 @@ public boolean isSettingsReadOnly() { return gameSettingsReadOnly; } + /// Enables instance-local running-directory selection for this instance. + /// + /// A blank local running directory resolves to the instance root. This operation is idempotent + /// and schedules a settings save only when it adds the override. It leaves the instance + /// unchanged when its local settings cannot be written safely. + public void enableIsolation() { + if (isSettingsReadOnly()) { + return; + } + + @Nullable GameSettings.Instance setting = getSettingsOrCreate(); + if (setting != null + && setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { + saveSettings(); + } + } + /// Backs up and overwrites the settings file when this instance still owns its settings. public void forceOverwriteSettings() { ensureGameSettingsLoaded(); 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 7676ec38f94..da8cf99b2d9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -21,7 +21,6 @@ import javafx.beans.binding.ObjectBinding; 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.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; @@ -49,7 +48,6 @@ import java.nio.file.Path; import java.time.Instant; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; import static org.jackhuang.hmcl.setting.SettingsManager.settings; @@ -67,9 +65,6 @@ public final class HMCLGameRepository extends DefaultGameRepository { /// The selected instance resolved from the current repository snapshot. private final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance; - /// 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 @@ -95,45 +90,6 @@ protected HMCLGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout 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, @@ -246,13 +202,18 @@ public void refreshSelectedInstance() { } /// Returns a dependency manager using the currently selected download provider. - public DefaultDependencyManager getDependency() { + /// + /// @return a new dependency manager for this repository + public HMCLDependencyManager getDependency() { return getDependency(DownloadProviders.getDownloadProvider()); } /// Returns a dependency manager using the given download provider. - public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) { - return new DefaultDependencyManager(this, downloadProvider, HMCLCacheRepository.REPOSITORY); + /// + /// @param downloadProvider the remote download provider + /// @return a new dependency manager for this repository + public HMCLDependencyManager getDependency(DownloadProvider downloadProvider) { + return new HMCLDependencyManager(this, downloadProvider, HMCLCacheRepository.REPOSITORY); } /// Resolves the run directory from modpack state and local settings. @@ -286,19 +247,6 @@ Path computeRunDirectory( } } - /// {@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) { - @Nullable PreparedInstanceSettings prepared = preparedInstanceSettings.get(instanceId); - return computeRunDirectory( - instanceId, - Files.exists(getLayout().getModpackConfigurationFile(instanceId)), - prepared != null ? prepared.settings() : getInstanceGameSettings(instanceId)); - } - private String selectedRunningDirectory( GameSettings.@Nullable Instance localSetting, boolean useInstanceRunningDirectory) { @@ -315,8 +263,8 @@ private String selectedRunningDirectory( /// 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. + /// Used while loading or migrating settings 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 @@ -325,9 +273,8 @@ private String selectedRunningDirectory( if (!Files.isRegularFile(file)) { return null; } - try { - return LauncherSettings.SETTINGS_GSON - .fromJson(Files.readString(file), GameSettings.Instance.class); + try (var reader = Files.newBufferedReader(file)) { + return LauncherSettings.SETTINGS_GSON.fromJson(reader, GameSettings.Instance.class); } catch (Exception e) { LOG.warning("Failed to peek instance game settings: " + file, e); return null; @@ -347,49 +294,11 @@ private void writeInstanceGameSettings(GameInstanceID instanceId, GameSettings.I FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); } - /// 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(); - } - 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(DefaultGameInstance::getVersion) - .thenComparing(instance -> Lang.requireNonNullElse(instance.getLaunchManifest().releaseTime(), Instant.EPOCH)) + .thenComparing(instance -> Lang.requireNonNullElse(instance.getResolvedManifest().releaseTime(), Instant.EPOCH)) .thenComparing(instance -> VersionNumber.asVersion(instance.getId().id()))); } @@ -520,6 +429,9 @@ public GameSettings.Effective getEffectiveGameSettings(GameInstanceID instanceId } /// Returns whether a new instance should use an isolated running directory under the default isolation settings. + /// + /// @param modded whether the new instance contains a mod loader + /// @return whether installation-time run-directory content should be placed under the instance root public boolean shouldIsolateNewInstance(boolean modded) { GameSettings.Preset preset = getParentGameSettings(null); DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED); @@ -530,17 +442,6 @@ 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)) { - return; - } - ensureIsolatedRunningDirectory(instanceId); - } - /// 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); @@ -629,14 +530,4 @@ public static long getAutoAllocatedMemory(long available, Platform platform) { : suggested; } - /// 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/HMCLModpackInstallTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java index 7352b02ab32..6b5efbfd769 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -19,6 +19,7 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.modpack.MinecraftInstanceTask; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; @@ -26,53 +27,110 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; +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.Collections; import java.util.List; +import java.util.regex.Matcher; +/// Installs or updates an HMCL modpack by rebuilding its recognized game components. +@NotNullByDefault public final class HMCLModpackInstallTask extends Task { private final Path zipFile; private final GameInstanceID instanceId; + + /// Existing instance selecting update mode, or `null` for a new installation. + private final @Nullable DefaultGameInstance updateTarget; + private final HMCLGameRepository repository; private final DefaultDependencyManager dependency; private final Modpack modpack; private final List> dependencies = new ArrayList<>(1); private final List> dependents = new ArrayList<>(4); - public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpack modpack, GameInstanceID instanceId) { + /// Creates a task that installs a new HMCL modpack instance. + /// + /// @param repository the target HMCL game repository + /// @param zipFile the HMCL modpack archive + /// @param modpack the parsed modpack metadata + /// @param instanceId the id of the new instance + public HMCLModpackInstallTask( + HMCLGameRepository repository, + Path zipFile, + Modpack modpack, + GameInstanceID instanceId) { + this(repository, zipFile, modpack, instanceId, null); + } + + /// Creates a task that updates an existing HMCL modpack instance. + /// + /// @param repository the target HMCL game repository + /// @param zipFile the HMCL modpack archive + /// @param modpack the parsed modpack metadata + /// @param instance the existing instance to update + /// @throws IllegalArgumentException if `instance` belongs to another repository, has no + /// modpack configuration, or records another provider type + public HMCLModpackInstallTask( + HMCLGameRepository repository, + Path zipFile, + Modpack modpack, + DefaultGameInstance instance) { + this(repository, zipFile, modpack, instance.getId(), instance); + } + + /// Creates an HMCL modpack task in the mode selected by `updateTarget`. + /// + /// @param repository the target HMCL game repository + /// @param zipFile the HMCL modpack archive + /// @param modpack the parsed modpack metadata + /// @param instanceId the target instance id + /// @param updateTarget the existing instance selecting update mode, or `null` for install + /// @throws IllegalArgumentException if an update target belongs to another repository or has + /// no compatible configuration + private HMCLModpackInstallTask( + HMCLGameRepository repository, + Path zipFile, + Modpack modpack, + GameInstanceID instanceId, + @Nullable DefaultGameInstance updateTarget) { this.repository = repository; this.dependency = repository.getDependency(); this.zipFile = zipFile; this.instanceId = instanceId; + this.updateTarget = updateTarget; this.modpack = modpack; + if (this.updateTarget != null) { + dependency.validateGameInstance(this.updateTarget); + } 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().id(this.instanceId).component(GameComponentType.GAME, modpack.getGameVersion()).buildAsync()); - - onDone().register(event -> { - if (event.isFailed()) repository.removeInstanceFromDisk(this.instanceId); - }); + if (this.updateTarget != null && Files.notExists(json)) + throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance."); - ModpackConfiguration config = null; + @Nullable ModpackConfiguration config = null; try { - if (Files.exists(json)) { + if (this.updateTarget != null && Files.exists(json)) { config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(Modpack.class)); - if (!HMCLModpackProvider.INSTANCE.getName().equals(config.getType())) + if (config == null || !HMCLModpackProvider.INSTANCE.getName().equals(config.getType())) throw new IllegalArgumentException("Instance " + instanceId + " is not a HMCL modpack. Cannot update this instance."); } } 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.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack")); + + onDone().register(event -> { + if (this.updateTarget == null && event.isFailed()) { + repository.removeInstanceFromDisk(this.instanceId); + } + }); + + dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), List.of("/minecraft"), it -> !"pack.json".equals(it), config)); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), List.of("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack")); } @Override @@ -85,32 +143,43 @@ public List> getDependents() { return dependents; } - /// {@inheritDoc} + /// Reads the legacy pack manifest and schedules one builder for all recognized components. + /// + /// The pack manifest is used only for component detection. The builder creates a new + /// patch-structured manifest instead of attempting to mutate the legacy flattened manifest. @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); + GameInstanceManifest originalManifest = JsonUtils.fromNonNullJson(json, GameInstanceManifest.class) + .withId(instanceId) + .withJar(null); GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null); - dependencies.add(repository.updateInstanceAsync(instanceId, publishedInstance -> { - Task libraryTask = Task.supplyAsync(() -> originalManifest); - // Forge and OptiFine libraries must be regenerated by their installers. + try (GameBuilder builder = updateTarget == null + ? dependency.newGameBuilder(instanceId) + : dependency.newGameBuilder(updateTarget)) { + builder.enableIsolation(); + + String gameVersion = modpack.getGameVersion(); + builder.component(GameComponentType.GAME, gameVersion); for (GameComponentAnalyzer.Mark mark : analyzer) { if (mark.componentType() == GameComponentType.GAME) { continue; } - String componentVersion = mark.version(); - if (componentVersion == null) { - continue; + + @Nullable String componentVersion = mark.version(); + if (componentVersion != null) { + if (mark.componentType() == GameComponentType.OPTIFINE) { + Matcher matcher = GameComponentAnalyzer.OPTIFINE_VERSION_PATTERN.matcher(componentVersion); + if (matcher.matches()) { + componentVersion = matcher.group("optifine"); + } + } + builder.component(mark.componentType(), componentVersion); } - libraryTask = libraryTask.thenComposeAsync(manifest -> dependency.installComponentAsync( - publishedInstance, - manifest, - modpack.getGameVersion(), - mark.componentType(), - componentVersion)); } - return libraryTask; - })); + dependencies.add(builder.buildAsync()); + } } + } 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 00a1233ba59..1170688d65b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java @@ -56,7 +56,7 @@ public Task createUpdateTask(DefaultDependencyManager dependencyManager, Defa throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository"); } - return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance.getId())); + return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance)); } @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 853c405eafa..aaef6607bce 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -157,8 +157,7 @@ private void launch0() { HMCLGameRepository repository = repository(); DefaultDependencyManager dependencyManager = repository.getDependency(); // Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch. - var launchManifest = new AtomicReference<>(LaunchManifestNormalizer.repairForLaunch( - gameInstance.getResolvedManifest().launchManifest())); + var launchManifest = new AtomicReference<>(LaunchManifestNormalizer.repairForLaunch(gameInstance.getResolvedManifest())); boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally(); CountDownLatch launchingLatch = new CountDownLatch(1); List javaAgents = new ArrayList<>(0); 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 7fdad1a09f2..65bef100c93 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java @@ -158,11 +158,9 @@ public static ModpackConfiguration readModpackConfiguration(Path file) throws } public static Task getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) { - repository.ensureIsolatedRunningDirectory(instanceId); - ExceptionalRunnable success = () -> { repository.refresh(); - repository.ensureIsolatedRunningDirectory(instanceId); + repository.getInstance(instanceId).enableIsolation(); }; ExceptionalConsumer failure = ex -> { @@ -198,11 +196,9 @@ public static Task getInstallManuallyCreatedModpackTask(Path zipFile, String } public static Task getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) { - repository.ensureIsolatedRunningDirectory(instanceId); - ExceptionalRunnable success = () -> { repository.refresh(); - repository.ensureIsolatedRunningDirectory(instanceId); + repository.getInstance(instanceId).enableIsolation(); }; ExceptionalConsumer failure = ex -> { @@ -230,12 +226,14 @@ 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: + case ServerModpackRemoteInstallTask.MODPACK_TYPE: { + HMCLGameInstance instance = repository.getInstance(instanceId); return new ModpackUpdateTask( - repository.getInstance(instanceId), - new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)) + instance, + new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instance)) .thenComposeAsync(repository.refreshAsync()) .withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries"))); + } default: throw new UnsupportedModpackException(); } 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 14306d19517..055a55ec9bb 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 @@ -20,8 +20,8 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameInstance; @@ -54,7 +54,7 @@ public AdditionalInstallersPage(HMCLGameInstance instance, String gameVersion, W component.setOnRemove(() -> { controller.getSettings().put( component.getComponentType().getPatchId(), - new UpdateInstallerWizardProvider.RemoveVersionAction(component.getComponentType())); + new UpdateInstallerWizardProvider.RemoveComponentAction(component.getComponentType())); reload(); }); } @@ -74,8 +74,8 @@ public String getTitle() { 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); + .flatMap(it -> Lang.tryCast(it, ComponentRemoteVersion.class)) + .map(ComponentRemoteVersion::getSelfVersion).orElse(null); } @Override @@ -87,7 +87,7 @@ protected void reload() { 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); + boolean alreadyInstalled = version != null && !(controller.getSettings().get(componentType.getPatchId()) instanceof UpdateInstallerWizardProvider.RemoveComponentAction); 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. 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 8de3cb718a3..cf5c19a9a7a 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 @@ -27,6 +27,8 @@ import org.jackhuang.hmcl.download.game.GameRemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLDependencyManager; +import org.jackhuang.hmcl.game.HMCLGameBuilder; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.DownloadProviders; @@ -282,7 +284,7 @@ public void onGameSelected() { private static class VanillaInstallWizardProvider implements WizardProvider { private final HMCLGameRepository repository; - private final DefaultDependencyManager dependencyManager; + private final HMCLDependencyManager dependencyManager; private final DownloadProvider downloadProvider; private final GameRemoteVersion gameVersion; @@ -300,24 +302,40 @@ public void start(SettingsMap settings) { settings.put(GameComponentType.GAME.getPatchId(), gameVersion); } - private Task finishVersionDownloadingAsync(SettingsMap settings) { - GameBuilder builder = dependencyManager.newGameBuilder(); - + /// Builds the selected instance and selects it after successful completion. + /// + /// @param settings the installer selections and target instance id + /// @return the builder task with its stage hints preserved as the outermost task wrapper + private Task finishVersionDownloadingAsync(SettingsMap settings) { GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID); - builder.id(instanceId); - builder.component(GameComponentType.GAME, ((RemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion()); - - settings.asStringMap().forEach((key, value) -> { - if (!GameComponentType.GAME.getPatchId().equals(key) - && value instanceof RemoteVersion remoteVersion) - builder.component(remoteVersion); - }); - - repository.applyDefaultIsolationSettingForNewInstance(instanceId, settings.isInstallingModdedVersion()); - return builder.buildAsync().whenComplete(any -> { - repository.refresh(); - repository.getInstance(instanceId).applyDefaultIsolationSetting(); - }).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId))); + if (instanceId == null) { + throw new IllegalStateException("Instance ID is not set"); + } + + try (HMCLGameBuilder builder = dependencyManager.newGameBuilder(instanceId)) { + builder.component(GameComponentType.GAME, ((ComponentRemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion()); + + settings.asStringMap().forEach((key, value) -> { + if (!GameComponentType.GAME.getPatchId().equals(key) + && value instanceof ComponentRemoteVersion remoteVersion) + builder.component(remoteVersion); + }); + + boolean modded = GameComponentType.MOD_LOADERS.stream() + .anyMatch(componentType -> + settings.get(componentType.getPatchId()) instanceof ComponentRemoteVersion); + if (repository.shouldIsolateNewInstance(modded)) { + builder.enableIsolation(); + } + + Task buildTask = builder.buildAsync(); + buildTask.onDone().register(event -> { + if (!event.isFailed()) { + runInFX(() -> repository.setSelectedInstance(repository.getInstance(instanceId))); + } + }); + return buildTask; + } } @Override @@ -333,7 +351,7 @@ public Object finish(SettingsMap settings) { public Node createPage(WizardController controller, int step, SettingsMap settings) { switch (step) { case 0: - return new InstallersPage(controller, repository, ((RemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider); + return new InstallersPage(controller, repository, ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion(), downloadProvider); default: throw new IllegalStateException("error step " + step + ", settings: " + settings + ", pages: " + controller.getPages()); } 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 2dcd0201277..eff5dfcdfa9 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,7 +18,7 @@ package org.jackhuang.hmcl.ui.download; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameRepository; @@ -52,11 +52,11 @@ public InstallersPage(WizardController controller, HMCLGameRepository repository @Override public String getTitle() { - return ((RemoteVersion) controller.getSettings().get("game")).getGameVersion(); + return ((ComponentRemoteVersion) controller.getSettings().get("game")).getGameVersion(); } private String getVersion(String id) { - return I18n.getDisplayVersion((RemoteVersion) controller.getSettings().get(id)); + return I18n.getDisplayVersion((ComponentRemoteVersion) controller.getSettings().get(id)); } protected void reload() { 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 dcd0aaa1372..562088d58c8 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 @@ -73,7 +73,7 @@ public Object finish(SettingsMap settings) { var hints = new ArrayList(); for (Object value : settings.asStringMap().values()) { - if (value instanceof RemoteVersion remoteVersion) { + if (value instanceof ComponentRemoteVersion remoteVersion) { hints.add(new Task.StagesHint("hmcl.install.libraries")); hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getComponentType().getPatchId(), remoteVersion.getSelfVersion()))); @@ -86,15 +86,13 @@ public Object finish(SettingsMap settings) { return gameInstance.getRepository().updateInstanceAsync(gameInstance.getId(), publishedInstance -> { Task update = Task.supplyAsync(publishedInstance::getManifest); for (Object value : settings.asStringMap().values()) { - if (value instanceof RemoteVersion remoteVersion) { + if (value instanceof ComponentRemoteVersion 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)); + dependencyManager.installComponentRemoteAsync(publishedInstance, manifest, remoteVersion)); + } else if (value instanceof RemoveComponentAction removeComponentAction) { + update = update.thenApplyAsync(manifest -> + manifest.removeComponent(removeComponentAction.componentType)); + } } return update; @@ -109,10 +107,10 @@ public Node createPage(WizardController controller, int step, SettingsMap settin if (oldLibraryVersion == null) { controller.onFinish(); } else if (componentType == GameComponentType.GAME) { - String newGameVersion = ((RemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion(); + String newGameVersion = ((ComponentRemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion(); controller.onNext(new AdditionalInstallersPage(gameInstance, newGameVersion, controller, downloadProvider)); } else { - Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + componentType.getPatchId()), oldLibraryVersion, ((RemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion()), + Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + componentType.getPatchId()), oldLibraryVersion, ((ComponentRemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion()), i18n("install.change_version"), controller::onFinish, controller::onCancel); } }); @@ -184,11 +182,6 @@ public static void alertFailureMessage(Exception exception, Runnable next) { } } - public static class RemoveVersionAction { - private final GameComponentType componentType; - - public RemoveVersionAction(GameComponentType componentType) { - this.componentType = componentType; - } + public record RemoveComponentAction(GameComponentType 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 82218e8dac5..62e2c49a942 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 @@ -31,8 +31,8 @@ import javafx.scene.input.KeyEvent; import javafx.scene.layout.*; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.RemoteVersion; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.download.cleanroom.CleanroomRemoteVersion; import org.jackhuang.hmcl.download.fabric.FabricAPIRemoteVersion; import org.jackhuang.hmcl.download.fabric.FabricRemoteVersion; @@ -82,10 +82,10 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab private final String title; private final Navigation navigation; private final DownloadProvider downloadProvider; - private final VersionList versionList; + private final ComponentVersionList versionList; private final Runnable callback; - private final ObservableList versions = FXCollections.observableArrayList(); + private final ObservableList versions = FXCollections.observableArrayList(); private final ObjectProperty status = new SimpleObjectProperty<>(Status.LOADING); public VersionsPage(Navigation navigation, @@ -153,7 +153,7 @@ private enum VersionTypeFilter { OLD } - private static class RemoteVersionListCell extends ListCell { + private static class RemoteVersionListCell extends ListCell { private final VersionsPage control; private final TwoLineListItem twoLineListItem = new TwoLineListItem(); @@ -196,7 +196,7 @@ private static class RemoteVersionListCell extends ListCell { } private void onAction() { - RemoteVersion item = getItem(); + ComponentRemoteVersion item = getItem(); if (item == null) return; @@ -205,7 +205,7 @@ private void onAction() { } private void onOpenWiki() { - RemoteVersion item = getItem(); + ComponentRemoteVersion item = getItem(); if (!(item instanceof GameRemoteVersion)) return; @@ -213,8 +213,8 @@ private void onOpenWiki() { } @Override - public void updateItem(RemoteVersion remoteVersion, boolean empty) { - RemoteVersion oldRemoteVersion = getItem(); + public void updateItem(ComponentRemoteVersion remoteVersion, boolean empty) { + ComponentRemoteVersion oldRemoteVersion = getItem(); ripplerContainer.releaseRippleImmediately(); super.updateItem(remoteVersion, empty); @@ -237,7 +237,7 @@ public void updateItem(RemoteVersion remoteVersion, boolean empty) { twoLineListItem.getTags().clear(); if (remoteVersion instanceof GameRemoteVersion) { - RemoteVersion.Type versionType = remoteVersion.getVersionType(); + ComponentRemoteVersion.Type versionType = remoteVersion.getVersionType(); GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()); switch (versionType) { @@ -246,7 +246,7 @@ public void updateItem(RemoteVersion remoteVersion, boolean empty) { imageView.setImage(GameInstanceIconType.GRASS.getIcon()); } case SNAPSHOT, PENDING, UNOBFUSCATED -> { - if (versionType == RemoteVersion.Type.SNAPSHOT + if (versionType == ComponentRemoteVersion.Type.SNAPSHOT && GameVersionNumber.asGameVersion(remoteVersion.getGameVersion()).isAprilFools()) { twoLineListItem.addTag(i18n("instance.game.april_fools")); imageView.setImage(GameInstanceIconType.APRIL_FOOLS.getIcon()); @@ -298,7 +298,7 @@ else if (remoteVersion instanceof QuiltRemoteVersion || remoteVersion instanceof } private static final class VersionsPageSkin extends SkinBase { - private final JFXListView list; + private final JFXListView list; private final TransitionPane transitionPane; private final JFXSpinner spinner; @@ -456,20 +456,20 @@ else if (status == Status.SUCCESS) } private void updateList() { - Stream versions = getSkinnable().versions.stream(); + Stream versions = getSkinnable().versions.stream(); VersionTypeFilter filter = categoryField.getSelectionModel().getSelectedItem(); if (filter != null) versions = versions.filter(it -> { - RemoteVersion.Type versionType = it.getVersionType(); + ComponentRemoteVersion.Type versionType = it.getVersionType(); return switch (filter) { - case RELEASE -> versionType == RemoteVersion.Type.RELEASE; - case SNAPSHOTS -> versionType == RemoteVersion.Type.SNAPSHOT - || versionType == RemoteVersion.Type.PENDING - || versionType == RemoteVersion.Type.UNOBFUSCATED; - case APRIL_FOOLS -> versionType == RemoteVersion.Type.SNAPSHOT + case RELEASE -> versionType == ComponentRemoteVersion.Type.RELEASE; + case SNAPSHOTS -> versionType == ComponentRemoteVersion.Type.SNAPSHOT + || versionType == ComponentRemoteVersion.Type.PENDING + || versionType == ComponentRemoteVersion.Type.UNOBFUSCATED; + case APRIL_FOOLS -> versionType == ComponentRemoteVersion.Type.SNAPSHOT && GameVersionNumber.asGameVersion(it.getGameVersion()).isAprilFools(); - case OLD -> versionType == RemoteVersion.Type.OLD; + case OLD -> versionType == ComponentRemoteVersion.Type.OLD; // case ALL, default -> true; }; 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 2e1b5f5c140..79e51780d22 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 @@ -2818,9 +2818,7 @@ private void initJavaSubtitle() { if (JavaManager.isInitialized()) { GameVersionNumber gameVersionNumber = currentGameVersion(); - GameInstanceManifest manifest = gameInstance != null - ? gameInstance.getResolvedManifest().launchManifest() - : null; + GameInstanceManifest manifest = gameInstance != null ? gameInstance.getResolvedManifest() : null; try { JavaRuntime java = effectiveSetting != null 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 ff38c42dc6f..ce98fb95a15 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 @@ -35,10 +35,7 @@ import org.jetbrains.annotations.Nullable; import java.nio.file.Path; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Objects; +import java.util.*; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -52,7 +49,7 @@ public class InstallerListPage extends ListPageBase { /// @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 -> { + FXUtils.applyDragListener(this, it -> Set.of("jar", "exe").contains(FileUtils.getExtension(it)), mods -> { if (!mods.isEmpty()) doInstallOffline(mods.get(0)); }); @@ -70,7 +67,8 @@ protected Skin createDefaultSkin() { } public void loadInstance(HMCLGameInstance.Optional instance) { - this.gameInstance = instance.instance(); + HMCLGameInstance gameInstance = instance.instance(); + this.gameInstance = gameInstance; if (gameInstance == null) { itemsProperty().clear(); return; @@ -148,14 +146,14 @@ public void installOffline() { } private void doInstallOffline(Path file) { - if (gameInstance == null) { + if (gameInstance == null || !gameInstance.getManifest().isModifiable()) { return; } HMCLGameRepository repository = gameInstance.getRepository(); Task task = repository.updateInstanceAsync( gameInstance.getId(), - publishedInstance -> repository.getDependency().installComponentAsync(publishedInstance, file)); + publishedInstance -> repository.getDependency().installComponentLocalAsync(publishedInstance, file)); task.setName(i18n("install.installer.install_offline")); TaskExecutor executor = task.executor(new TaskListener() { @Override 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 8059594e6f7..665983ba4a5 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 @@ -175,10 +175,12 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { GameInstanceManifest manifest; try { manifest = JsonUtils.fromJsonFile(file, GameInstanceManifest.class); - if (manifest == null) { + if (manifest == null) throw new IllegalArgumentException("Missing game manifest"); - } + if (manifest.inheritsFrom() != null) + throw new IllegalArgumentException("Game manifest inherits from another manifest"); } catch (Exception e) { + LOG.warning("Failed to read game manifest from " + file, e); Controllers.dialog(i18n("install.new_game.malformed_json"), i18n("message.error"), MessageDialogPane.MessageType.ERROR); return; } @@ -274,7 +276,7 @@ public static void updateInstance(HMCLGameInstance gameInstance) { public static void updateGameAssets(HMCLGameInstance gameInstance) { TaskExecutor executor = new GameAssetDownloadTask( gameInstance.getRepository().getDependency(), - gameInstance.getResolvedManifest().launchManifest(), + gameInstance.getResolvedManifest(), GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true).executor(); Controllers.taskDialog(executor, i18n("instance.manage.redownload_assets_index"), TaskCancellationAction.NO_CANCEL); 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 b7e08bb4ac4..52c32536af8 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 @@ -44,9 +44,8 @@ import javafx.scene.text.TextFlow; import javafx.util.Duration; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameDirectoryManager; @@ -81,7 +80,7 @@ import java.util.concurrent.CancellationException; import java.util.function.Consumer; -import static org.jackhuang.hmcl.download.RemoteVersion.Type.RELEASE; +import static org.jackhuang.hmcl.download.ComponentRemoteVersion.Type.RELEASE; import static org.jackhuang.hmcl.setting.SettingsManager.state; import static org.jackhuang.hmcl.ui.FXUtils.SINE; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -361,7 +360,7 @@ private void launch() { private void launchNoGame() { DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider(); - VersionList versionList = downloadProvider.getVersionList(GameComponentType.GAME); + ComponentVersionList versionList = downloadProvider.getVersionList(GameComponentType.GAME); Holder instanceHolder = new Holder<>(); Task task = versionList.refreshAsync("") @@ -373,19 +372,19 @@ private void launchNoGame() { .orElseThrow(() -> new IOException("No versions found"))) .thenComposeAsync(version -> { HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); - DefaultDependencyManager dependency = repository.getDependency(); + HMCLDependencyManager dependency = repository.getDependency(); String gameVersion = version.getGameVersion(); GameInstanceID instanceId = new GameInstanceID(gameVersion); instanceHolder.value = instanceId; - return dependency.newGameBuilder() - .id(instanceId) - .component(GameComponentType.GAME, gameVersion) - .buildAsync(); + try (HMCLGameBuilder builder = dependency.newGameBuilder(instanceId)) { + return builder + .component(GameComponentType.GAME, gameVersion) + .buildAsync(); + } }) - .whenComplete(any -> GameDirectoryManager.getSelectedRepository().refresh()) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java index 286910be07e..f7ebd93a02c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/I18n.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.util.i18n; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.download.game.GameRemoteVersion; import org.jackhuang.hmcl.util.i18n.translator.Translator; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -77,7 +77,7 @@ public static String formatSpeed(long bytes) { return getTranslator().formatSpeed(bytes); } - public static String getDisplayVersion(RemoteVersion version) { + public static String getDisplayVersion(ComponentRemoteVersion version) { return getTranslator().getDisplayVersion(version); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator.java index 7bdcfc80383..223a238f152 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.util.i18n.translator; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.util.i18n.SupportedLocale; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -44,7 +44,7 @@ public final Locale getDisplayLocale() { return displayLocale; } - public String getDisplayVersion(RemoteVersion remoteVersion) { + public String getDisplayVersion(ComponentRemoteVersion remoteVersion) { return remoteVersion.getSelfVersion(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_en_Qabs.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_en_Qabs.java index f593daf5f80..ec140cb867f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_en_Qabs.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_en_Qabs.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.util.i18n.translator; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.util.i18n.SupportedLocale; import java.io.IOException; @@ -73,7 +73,7 @@ public Translator_en_Qabs(SupportedLocale locale) { } @Override - public String getDisplayVersion(RemoteVersion remoteVersion) { + public String getDisplayVersion(ComponentRemoteVersion remoteVersion) { return translate(remoteVersion.getSelfVersion()); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_lzh.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_lzh.java index 25f1966e14e..dc561dff0a4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_lzh.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/i18n/translator/Translator_lzh.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.util.i18n.translator; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.download.game.GameRemoteVersion; import org.jackhuang.hmcl.util.i18n.SupportedLocale; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -221,7 +221,7 @@ public Translator_lzh(SupportedLocale locale) { } @Override - public String getDisplayVersion(RemoteVersion remoteVersion) { + public String getDisplayVersion(ComponentRemoteVersion remoteVersion) { if (remoteVersion instanceof GameRemoteVersion) return translateGameVersion(GameVersionNumber.asGameVersion(remoteVersion.getSelfVersion())); else 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 95c9aa4f95f..75eba993aeb 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -439,14 +439,14 @@ public void repositoryDirectoryFollowsGameDirectoryPath() throws ReflectiveOpera } } - /// Tests that an unpublished isolated installation resolves paths without a [HMCLGameInstance]. + /// Tests that a default isolation decision is persisted after a new instance is published. @Test - public void newIsolatedInstallationUsesVersionRootBeforePublication(@TempDir Path tempDirectory) + public void newInstancePersistsDefaultIsolationDecisionAfterPublication(@TempDir Path tempDirectory) throws Exception { GameSettingsPresetID defaultPresetId = GameSettingsPresetID.parse("game-settings-preset:123e4567-e89b-12d3-a456-426614174002"); GameSettings.Preset defaultPreset = new GameSettings.Preset(defaultPresetId); - defaultPreset.defaultIsolationTypeProperty().setValue(DefaultIsolationType.MODDED); + defaultPreset.defaultIsolationTypeProperty().setValue(DefaultIsolationType.ALWAYS); GameSettingsPresets presets = new GameSettingsPresets(); presets.getPresets().setAll(defaultPreset); @@ -465,18 +465,22 @@ public void newIsolatedInstallationUsesVersionRootBeforePublication(@TempDir Pat GameInstanceID id = new GameInstanceID("1.21.11-fabric"); assertFalse(repository.hasInstance(id)); + boolean isolated = repository.shouldIsolateNewInstance(false); + assertTrue(isolated); - 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(); } HMCLGameInstance instance = repository.getInstance(id); + if (isolated) { + instance.enableIsolation(); + } + FileSaver.waitForAllSaves(); + repository.refresh(); + instance = repository.getInstance(id); assertEquals(repository.getLayout().getInstanceRoot(id), instance.getRunDirectory()); assertEquals(repository.getLayout().getInstanceRoot(id).resolve("mods"), instance.getModsDirectory()); GameSettings.Instance instanceSettings = Objects.requireNonNull(instance.getSettings()); 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 beec622e2b2..0d327b11945 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java @@ -31,7 +31,7 @@ public abstract class AbstractDependencyManager implements DependencyManager { public abstract DefaultCacheRepository getCacheRepository(); @Override - public VersionList getVersionList(GameComponentType componentType) { + public ComponentVersionList 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 45a4ae2a1be..110dced2173 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java @@ -18,19 +18,24 @@ package org.jackhuang.hmcl.download; import org.jackhuang.hmcl.game.GameComponentType; +import org.jackhuang.hmcl.task.Task; import java.net.URI; +import java.util.Arrays; +import java.util.Collection; import java.util.LinkedHashSet; import java.util.List; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.function.Function; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + /// @author huangyuhui 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, @@ -96,9 +101,9 @@ public List injectURLsWithCandidates(List urls) { } @Override - public VersionList getVersionList(GameComponentType componentType) { + public ComponentVersionList getVersionList(GameComponentType componentType) { return versionLists.computeIfAbsent(componentType, value -> { - VersionList[] lists = new VersionList[versionListProviders.size()]; + ComponentVersionList[] lists = new ComponentVersionList[versionListProviders.size()]; for (int i = 0; i < versionListProviders.size(); i++) { lists[i] = versionListProviders.get(i).getVersionList(value); } @@ -115,4 +120,90 @@ public int getConcurrency() { public String toString() { return "AutoDownloadProvider[versionListProviders=%s, fileProviders=%s]".formatted(versionListProviders, fileProviders); } + + private static final class MultipleSourceVersionList extends ComponentVersionList { + private final ComponentVersionList[] backends; + + MultipleSourceVersionList(ComponentVersionList[] backends) { + this.backends = backends; + + assert (backends.length >= 1); + } + + @Override + public boolean hasType() { + boolean hasType = backends[0].hasType(); + assert (Arrays.stream(backends).allMatch(versionList -> versionList.hasType() == hasType)); + return hasType; + } + + @Override + public Task refreshAsync() { + throw new UnsupportedOperationException("MultipleSourceVersionList does not support loading the entire remote version list."); + } + + private Task refreshAsync(String gameVersion, int sourceIndex) { + ComponentVersionList versionList = backends[sourceIndex]; + Task refreshTask = versionList.refreshAsync(gameVersion); + + return new Task<>() { + private Task nextTask = null; + + { + setSignificance(TaskSignificance.MODERATE); + setName("MultipleSourceVersionList.refreshAsync(task=%s, index=%d, all=%d)".formatted( + refreshTask.getName(), sourceIndex, backends.length) + ); + } + + @Override + public Collection> getDependents() { + return List.of(refreshTask); + } + + @Override + public Collection> getDependencies() { + return nextTask != null ? List.of(nextTask) : List.of(); + } + + @Override + public boolean isRelyingOnDependents() { + return false; + } + + @Override + public void execute() throws Exception { + if (isDependentsSucceeded()) { + lock.writeLock().lock(); + try { + versions.putAll(gameVersion, versionList.getVersions(gameVersion)); + } finally { + lock.writeLock().unlock(); + } + + setResult(refreshTask.getResult()); + } else { + Exception exception = refreshTask.getException(); + assert exception != null; + + if (sourceIndex == backends.length - 1) { + LOG.warning("Failed to fetch versions list from all sources", exception); + setSignificance(TaskSignificance.MINOR); + throw exception; + } else { + LOG.warning("Failed to fetch versions list and try to fetch from other source", exception); + nextTask = refreshAsync(gameVersion, sourceIndex + 1); + nextTask.storeTo(this::setResult); + } + } + } + }; + } + + @Override + public Task refreshAsync(String gameVersion) { + versions.clear(gameVersion); + return refreshAsync(gameVersion, 0); + } + } } 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 8db94c8d1b6..b301f6f1969 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java @@ -121,7 +121,7 @@ public List getAssetObjectCandidates(String assetObjectLocation) { } @Override - public VersionList getVersionList(GameComponentType componentType) { + public ComponentVersionList getVersionList(GameComponentType componentType) { return switch (componentType) { case GAME -> game; case FABRIC -> fabric; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/ComponentRemoteVersion.java similarity index 64% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/download/ComponentRemoteVersion.java index 6c71b808624..5d9e0769ced 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/ComponentRemoteVersion.java @@ -29,12 +29,10 @@ import java.util.List; import java.util.Objects; -/** - * The remote version. - * - * @author huangyuhui - */ -public class RemoteVersion implements Comparable { +/// The remote version. +/// +/// @author huangyuhui +public abstract class ComponentRemoteVersion implements Comparable { private final GameComponentType componentType; private final String gameVersion; @@ -43,25 +41,21 @@ public class RemoteVersion implements Comparable { private final List urls; private final Type type; - /** - * Constructor. - * - * @param gameVersion the Minecraft version that this remote version suits. - * @param selfVersion the version string of the remote version. - * @param urls the installer or universal jar original URL. - */ - public RemoteVersion(GameComponentType componentType, String gameVersion, String selfVersion, Instant releaseDate, List urls) { + /// Constructor. + /// + /// @param gameVersion the Minecraft version that this remote version suits. + /// @param selfVersion the version string of the remote version. + /// @param urls the installer or universal jar original URL. + public ComponentRemoteVersion(GameComponentType componentType, String gameVersion, String selfVersion, Instant releaseDate, List urls) { this(componentType, gameVersion, selfVersion, releaseDate, Type.UNCATEGORIZED, urls); } - /** - * Constructor. - * - * @param gameVersion the Minecraft version that this remote version suits. - * @param selfVersion the version string of the remote version. - * @param urls the installer or universal jar URL. - */ - public RemoteVersion(GameComponentType componentType, String gameVersion, String selfVersion, Instant releaseDate, Type type, List urls) { + /// Constructor. + /// + /// @param gameVersion the Minecraft version that this remote version suits. + /// @param selfVersion the version string of the remote version. + /// @param urls the installer or universal jar URL. + public ComponentRemoteVersion(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); @@ -98,30 +92,21 @@ public Type getVersionType() { return type; } - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - throw new UnsupportedOperationException(this + " cannot be installed yet"); - } - - /// Creates an install task with an explicit mods directory for libraries that download into the + /// Creates an installation 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 baseManifest the manifest being installed into /// @param modsDirectory the mods directory of the target instance run directory - /// @return the install task - public Task getInstallTask( + /// @return the installation task + public abstract Task getInstallTask( DefaultDependencyManager dependencyManager, - GameInstanceManifest baseVersion, - Path modsDirectory) { - return getInstallTask(dependencyManager, baseVersion); - } + GameInstanceManifest baseManifest, + Path modsDirectory); @Override public boolean equals(Object obj) { - return obj instanceof RemoteVersion && Objects.equals(selfVersion, ((RemoteVersion) obj).selfVersion); + return obj instanceof ComponentRemoteVersion && Objects.equals(selfVersion, ((ComponentRemoteVersion) obj).selfVersion); } @Override @@ -138,7 +123,7 @@ public String toString() { } @Override - public int compareTo(RemoteVersion o) { + public int compareTo(ComponentRemoteVersion o) { // newer versions are smaller than older versions return VersionNumber.asVersion(o.selfVersion).compareTo(VersionNumber.asVersion(selfVersion)); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/VersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/ComponentVersionList.java similarity index 84% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/download/VersionList.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/download/ComponentVersionList.java index 0ac125384c5..6e900c07eb9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/VersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/ComponentVersionList.java @@ -23,20 +23,18 @@ import java.util.*; import java.util.concurrent.locks.ReentrantReadWriteLock; -/** - * The remote version list. - * - * @param The subclass of {@code RemoteVersion}, the type of RemoteVersion. - * @author huangyuhui - */ -public abstract class VersionList { +/// The remote version list. +/// +/// @param the type of ComponentRemoteVersion. +/// @author huangyuhui +public abstract class ComponentVersionList { /** * the remote version list. * key: game version. * values: corresponding remote versions. */ - protected final SimpleMultimap> versions = new SimpleMultimap<>(HashMap::new, TreeSet::new); + protected final SimpleMultimap> versions = new SimpleMultimap<>(HashMap::new, TreeSet::new); /** * True if the version list has been loaded. @@ -82,7 +80,7 @@ public Task loadAsync(String gameVersion) { }); } - protected Collection getVersionsImpl(String gameVersion) { + protected Collection getVersionsImpl(String gameVersion) { return versions.get(gameVersion); } @@ -92,7 +90,7 @@ protected Collection getVersionsImpl(String gameVersion) { * @param gameVersion the Minecraft version that remote versions belong to * @return the collection of specific remote versions */ - public final Collection getVersions(String gameVersion) { + public final Collection getVersions(String gameVersion) { lock.readLock().lock(); try { return Collections.unmodifiableCollection(new ArrayList<>(getVersionsImpl(gameVersion))); @@ -108,16 +106,16 @@ public final Collection getVersions(String gameVersion) { * @param remoteVersion the version of the remote version. * @return the specific remote version, null if it is not found. */ - public Optional getVersion(String gameVersion, String remoteVersion) { + public Optional getVersion(String gameVersion, String remoteVersion) { lock.readLock().lock(); try { - T result = null; - TreeSet remoteVersions = versions.get(gameVersion); - for (T it : remoteVersions) + V result = null; + TreeSet remoteVersions = versions.get(gameVersion); + for (V it : remoteVersions) if (remoteVersion.equals(it.getSelfVersion())) result = it; if (result == null) - for (T it : remoteVersions) + for (V it : remoteVersions) if (remoteVersion.equals(it.getFullVersion())) result = it; return Optional.ofNullable(result); 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 b9b34c3b62f..93f029b8039 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -36,9 +36,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; -import java.util.Optional; import java.util.regex.Matcher; -import java.util.regex.Pattern; /// Provides downloads and game-component installation for one game repository. @NotNullByDefault @@ -90,8 +88,79 @@ public DefaultCacheRepository getCacheRepository() { } @Override - public GameBuilder newGameBuilder() { - return new DefaultGameBuilder(this); + public DefaultGameBuilder newGameBuilder(GameInstanceID instanceId) { + GameInstanceManifest initialManifest = new GameInstanceManifest(instanceId); + DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, null); + return new DefaultGameBuilder(this, instanceId, null, draft, initialManifest); + } + + @Override + public DefaultGameBuilder newGameBuilder(GameInstance instance) { + validateGameInstance(instance); + DefaultGameInstance updateTarget = (DefaultGameInstance) instance; + + GameInstanceManifest initialManifest = new GameInstanceManifest(updateTarget.getId()); + DefaultGameRepositoryDraft draft = openGameBuilderDraft(initialManifest, updateTarget); + return new DefaultGameBuilder( + this, updateTarget.getId(), updateTarget, draft, initialManifest); + } + + /// Opens an exclusive draft and reserves a builder target in it. + /// + /// The exact `updateTarget` object must be present in the draft's base snapshot. An empty target + /// manifest is retained before this method returns. Any validation or reservation failure aborts + /// the draft before it is propagated. + /// + /// @param initialManifest the empty target manifest to reserve + /// @param updateTarget the exact published instance with the same id required for an update, + /// or `null` + /// @return the open draft containing the reserved target + /// @throws IllegalArgumentException if `updateTarget` belongs to another repository + /// @throws IllegalStateException if the target conflicts with the selected operation, cannot + /// be reserved, or another repository draft is open + protected DefaultGameRepositoryDraft openGameBuilderDraft( + GameInstanceManifest initialManifest, + @Nullable DefaultGameInstance updateTarget) { + if (updateTarget != null) { + validateGameInstance(updateTarget); + } + + GameInstanceID instanceId = initialManifest.id(); + DefaultGameRepositoryDraft draft = repository.openDraft(); + try { + @Nullable DefaultGameInstance currentInstance = draft.getBaseSnapshot().findInstance(instanceId); + if (updateTarget == null) { + if (currentInstance != null) { + throw new IllegalStateException("Game instance already exists: " + instanceId); + } + } else if (currentInstance == null) { + throw new IllegalStateException("Game instance no longer exists: " + instanceId); + } else if (currentInstance != updateTarget) { + throw new IllegalStateException("Game instance has changed: " + instanceId); + } + draft.put(initialManifest); + return draft; + } catch (IOException e) { + abortDraftAfterFailure(draft, e); + throw new IllegalStateException("Cannot reserve game instance " + instanceId, e); + } catch (RuntimeException | Error failure) { + abortDraftAfterFailure(draft, failure); + throw failure; + } + } + + /// Aborts a draft and attaches a cleanup failure to the triggering failure. + /// + /// @param draft the draft to abort + /// @param failure the triggering failure that receives any cleanup failure as suppressed + static void abortDraftAfterFailure( + DefaultGameRepositoryDraft draft, + Throwable failure) { + try { + draft.abort(); + } catch (IOException cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } } @Override @@ -107,7 +176,7 @@ public Task checkGameCompletionAsync( return Files.notExists(instanceJar) || FileUtils.size(instanceJar) == 0L ? new GameDownloadTask(this, manifest).thenAcceptAsync( - cachedJar -> FileUtils.copyFile(cachedJar, instanceJar)) + cachedJar -> FileUtils.copyFile(cachedJar, instanceJar)) : null; }).thenComposeAsync(checkPatchCompletionAsync(instance, manifest, integrityCheck)), new GameAssetDownloadTask(this, manifest, GameAssetDownloadTask.DOWNLOAD_INDEX_IF_NECESSARY, integrityCheck) @@ -136,37 +205,44 @@ public Task checkPatchCompletionAsync( String gameVersion = detectedVersion.toString(); GameInstanceManifest original = instance.getManifest(); - for (GameComponentType type : GameComponentType.values()) { - if (!instance.hasComponent(type)) - continue; - - 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(() -> instance.getResolvedManifest().standaloneManifest().getPatches().stream() - .filter(patch -> "optifine".equals(patch.id())) - .findAny() - .map(GameInstancePatch::version) - .orElse(null)); - - boolean needsReInstallation = manifest.getLibraries().stream() - .anyMatch(library -> !library.hasDownloadURL() - && "optifine".equals(library.groupId()) - && GameLibrariesTask.shouldDownloadLibrary(repository, manifest, library, integrityCheck)); - - if (needsReInstallation) { - Library installer = new Library(new Artifact("optifine", "OptiFine", gameVersion + "_" + optifinePatchVersion, "installer")); - if (GameLibrariesTask.shouldDownloadLibrary(repository, manifest, installer, integrityCheck)) { - tasks.add(installComponentAsync(instance, original, gameVersion, GameComponentType.OPTIFINE, optifinePatchVersion)); - } else { - tasks.add(OptiFineInstallTask.install( - this, - original, - gameVersion, - repository.getLayout().getLibraryFile(manifest.id(), installer))); - } + + optifine: + { + @Nullable GameComponentAnalyzer.Mark mark = instance.getAnalyzer().getMark(GameComponentType.OPTIFINE); + if (mark == null || mark.version() == null) + break optifine; + + @Nullable GameInstancePatch patch = original.findPatch(GameComponentType.OPTIFINE); + String fullVersion; + String patchVersion; + if (patch != null && patch.version() != null) { + patchVersion = patch.version(); + fullVersion = gameVersion + "_" + patchVersion; + } else { + fullVersion = mark.version(); + Matcher matcher = GameComponentAnalyzer.OPTIFINE_VERSION_PATTERN.matcher(fullVersion); + if (matcher.matches()) { + patchVersion = matcher.group("optifine"); + } else { + break optifine; + } + } + + boolean needsReInstallation = manifest.getLibraries().stream() + .anyMatch(library -> !library.hasDownloadURL() + && "optifine".equals(library.groupId()) + && GameLibrariesTask.shouldDownloadLibrary(repository, manifest, library, integrityCheck)); + + if (needsReInstallation) { + Library installer = new Library("optifine", "OptiFine", fullVersion, "installer"); + if (GameLibrariesTask.shouldDownloadLibrary(repository, manifest, installer, integrityCheck)) { + tasks.add(installComponentRemoteAsync(instance, original, gameVersion, GameComponentType.OPTIFINE, patchVersion)); + } else { + tasks.add(OptiFineInstallTask.install( + this, + original, + gameVersion, + repository.getLayout().getLibraryFile(manifest.id(), installer))); } } } @@ -175,122 +251,91 @@ public Task checkPatchCompletionAsync( }); } - /// 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 + /// Installs a component into an unpublished working manifest 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 baseManifest the working manifest for this step + /// @param modsDirectory the mods directory to use during installation /// @param componentVersion the remote component to install /// @return the task producing the updated manifest (not yet committed) - Task installNewInstanceComponentAsync( - GameInstanceID instanceId, + Task installUnpublishedComponentAsync( 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", + Path modsDirectory, + ComponentRemoteVersion componentVersion) { + return Task.composeAsync(() -> { + GameInstanceManifest manifest = baseManifest.removeComponent(componentVersion.getComponentType()); + return componentVersion + .getInstallTask(this, manifest, modsDirectory) + .thenApplyAsync(patch -> patch == null + ? manifest + : manifest.addPatch(patch).reconstructByPatches() + ); + }) + .withStage("hmcl.install.%s:%s".formatted( componentVersion.getComponentType().getPatchId(), componentVersion.getSelfVersion())); } - /// Resolves and installs a component into an unpublished new instance. + /// Resolves and installs a component into an unpublished working manifest. /// - /// @param instanceId the unpublished instance id /// @param baseManifest the working manifest for this step + /// @param modsDirectory the mods directory to use during installation /// @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, + Task installUnpublishedComponentAsync( GameInstanceManifest baseManifest, + Path modsDirectory, String gameVersion, GameComponentType componentType, String componentVersion) { - if (!instanceId.equals(baseManifest.id())) { - throw new IllegalArgumentException("baseManifest id does not match instanceId"); - } - - VersionList versionList = getVersionList(componentType); + ComponentVersionList versionList = getVersionList(componentType); return versionList.loadAsync(gameVersion) - .thenComposeAsync(() -> installNewInstanceComponentAsync( - instanceId, + .thenComposeAsync(() -> installUnpublishedComponentAsync( baseManifest, - gameVersion, + modsDirectory, versionList.getVersion(gameVersion, componentVersion) .orElseThrow(() -> new IOException( "Remote component " + componentType + " has no version " + componentVersion)))) - .withStage(String.format("hmcl.install.%s:%s", componentType, componentVersion)); + .withStage("hmcl.install.%s:%s".formatted(componentType, componentVersion)); } - /// Removes one component from an unpublished new instance manifest. + /// Installs a component using a working manifest that may be ahead of the instance's stored state. /// - /// @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); - }); + /// 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 componentVersion the remote component to install + /// @return the task producing the updated manifest (not yet saved) + public Task installComponentRemoteAsync( + GameInstance instance, + GameInstanceManifest baseManifest, + ComponentRemoteVersion componentVersion) { + validateGameInstance(instance); + if (!instance.getId().equals(baseManifest.id())) { + throw new IllegalArgumentException("baseManifest id does not match instance"); + } + if (!baseManifest.isModifiable()) { + throw new IllegalArgumentException("Cannot install component into a non-modifiable manifest"); + } + + Path modsDirectory = instance.getModsDirectory(); + return installUnpublishedComponentAsync(baseManifest, modsDirectory, componentVersion); } /// 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 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( + public Task installComponentRemoteAsync( GameInstance instance, GameInstanceManifest baseManifest, String gameVersion, @@ -301,15 +346,15 @@ public Task installComponentAsync( throw new IllegalArgumentException("baseManifest id does not match instance"); } - VersionList versionList = getVersionList(componentType); + ComponentVersionList versionList = getVersionList(componentType); return versionList.loadAsync(gameVersion) - .thenComposeAsync(() -> installComponentAsync( + .thenComposeAsync(() -> installComponentRemoteAsync( 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)); + "Remote component " + componentType + " has no version " + componentVersion)))) + .withStage("hmcl.install.%s:%s".formatted(componentType, componentVersion)); } /// Installs a component from a local installer jar into a registered instance. @@ -317,25 +362,15 @@ public Task installComponentAsync( /// @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) { + public Task installComponentLocalAsync(GameInstance instance, Path installer) { validateGameInstance(instance); - return installComponentAsync(instance, instance.getManifest(), installer); - } - /// 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"); + GameInstanceManifest baseManifest = instance.getManifest(); + + if (!baseManifest.isModifiable()) { + throw new IllegalArgumentException("Cannot install component into a non-modifiable manifest"); } + String gameVersion = instance.getVersion().toString(); return Task.composeAsync(() -> { @@ -361,7 +396,9 @@ public Task installComponentAsync( throw new UnsupportedLibraryInstallerException(); }) - .thenApplyAsync(patch -> patch == null ? baseManifest : baseManifest.addPatch(patch)); + .thenApplyAsync(patch -> patch == null + ? baseManifest + : baseManifest.addPatch(patch).reconstructByPatches()); } /// Indicates that a local library installer is not recognized by any supported installer. @@ -379,39 +416,17 @@ public UnsupportedLibraryInstallerException() { /// @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) { + GameInstanceManifest workingManifest = instance.getManifest(); validateGameInstance(instance); if (!instance.getId().equals(workingManifest.id())) { throw new IllegalArgumentException("workingManifest id does not match instance"); } - return Task.supplyAsync(() -> { - GameInstanceManifest standalone; - if (workingManifest.equals(instance.getManifest())) { - standalone = instance.getResolvedManifest().standaloneManifest(); - } else if (workingManifest.inheritsFrom() == null) { - standalone = workingManifest; - } else { - standalone = repository.resolve(workingManifest).standaloneManifest(); - } + if (!workingManifest.isModifiable()) { + throw new IllegalArgumentException("Cannot remove component from a non-modifiable manifest"); + } - return GameComponentAnalyzer.analyze(standalone, instance.getVersion()).removeLibrary(componentType); - }); + return Task.completed(workingManifest.removeComponent(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 c6289d83528..404a43a73a0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -20,14 +20,25 @@ import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Map; -import java.util.Objects; -/// Builds a new game instance in an exclusive [GameRepositoryDraft], installs its components, and -/// publishes the completed instance once. +/// Installs a new game instance or replaces an existing instance in an exclusive +/// [GameRepositoryDraft], then publishes the completed instance once. +/// +/// Each builder receives a target already reserved by its dependency manager in an exclusive +/// draft. An update requires the supplied snapshot-bound instance to remain the exact currently +/// published object when the manager creates the builder. +/// +/// Replacement starts from an empty manifest with the target id and installs only the configured +/// components. It retains the existing run directory unless isolation is explicitly enabled. /// /// 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. @@ -37,11 +48,44 @@ 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. + /// Id of the instance to create or replace. + private final GameInstanceID instanceId; + + /// Existing instance selecting update mode, or `null` for a new installation. + private final @Nullable DefaultGameInstance updateTarget; + + /// Exclusive repository draft retained until the build task takes ownership or this builder closes. + private final DefaultGameRepositoryDraft draft; + + /// Empty manifest reserved in the draft for the target instance. + private final GameInstanceManifest initialManifest; + + /// Whether ownership of [#draft] has been transferred to the task returned by [#buildAsync()]. + private boolean draftTransferred; + + /// Whether instance isolation was requested for this build. + protected boolean isolationEnabled; + + /// Creates a builder around a target already reserved by its dependency manager. /// /// @param dependencyManager the dependency manager for the target repository - public DefaultGameBuilder(DefaultDependencyManager dependencyManager) { + /// @param instanceId the id of the reserved instance and `initialManifest` + /// @param updateTarget the exact update target retained by the draft, or `null` for install + /// @param draft the open draft containing `initialManifest`; it must belong to + /// `dependencyManager` + /// @param initialManifest the empty target manifest retained by `draft`; its id must equal + /// `instanceId` + protected DefaultGameBuilder( + DefaultDependencyManager dependencyManager, + GameInstanceID instanceId, + @Nullable DefaultGameInstance updateTarget, + DefaultGameRepositoryDraft draft, + GameInstanceManifest initialManifest) { this.dependencyManager = dependencyManager; + this.instanceId = instanceId; + this.updateTarget = updateTarget; + this.draft = draft; + this.initialManifest = initialManifest; } /// Returns the dependency manager used by this builder. @@ -51,77 +95,137 @@ public DefaultDependencyManager getDependencyManager() { return dependencyManager; } + /// {@inheritDoc} + @Override + @Contract("-> this") + public DefaultGameBuilder enableIsolation() { + checkOpen(); + isolationEnabled = true; + return this; + } + /// {@inheritDoc} /// - /// Retains an unpublished working manifest, installs the configured game and optional loaders, - /// resolves them into launch and standalone views, and commits the standalone manifest once. - /// Failure or cancellation aborts the draft. + /// Installs the configured game and optional loaders into the draft reserved by this builder, + /// resolves the launch view, and commits it with the original patches once. Failure or + /// cancellation aborts the transferred draft. /// /// @return the build task - /// @throws NullPointerException if [#id] was not set + /// @throws IllegalStateException if the configured game version is absent, the builder is + /// closed, or this method has already returned a task @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(); - - 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))); - - if (componentType == GameComponentType.GAME) { - hints.add(new Task.StagesHint("hmcl.install.libraries")); - hints.add(new Task.StagesHint("hmcl.install.assets")); - } - }); - - - DefaultGameRepository repository = dependencyManager.getGameRepository(); - //noinspection resource - DefaultGameRepositoryDraft draft = repository.openDraft(); - - Task libraryTask = dependencyManager.installNewInstanceComponentAsync( - id, new GameInstanceManifest(id), gameVersion, GameComponentType.GAME, gameVersion); - - for (Map.Entry entry : components.entrySet()) { - GameComponentType componentType = entry.getKey(); - if (componentType == GameComponentType.GAME) - continue; - - 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()); + checkOpen(); + try { + @Nullable String gameVersion = (String) components.get(GameComponentType.GAME); + if (gameVersion == null) + throw new IllegalStateException("GameBuilder.gameVersion must be set"); + + var hints = new ArrayList(); + + components.forEach((componentType, version) -> { + hints.add(new Task.StagesHint( + "hmcl.install.%s:%s".formatted( + componentType.getPatchId(), + version instanceof ComponentRemoteVersion remoteVersion + ? remoteVersion.getSelfVersion() + : (String) version))); + + if (componentType == GameComponentType.GAME) { + hints.add(new Task.StagesHint("hmcl.install.libraries")); + hints.add(new Task.StagesHint("hmcl.install.assets")); + } + }); + + DefaultGameRepository repository = dependencyManager.getGameRepository(); + Path runDirectory = isolationEnabled + ? repository.getLayout().getInstanceRoot(instanceId) + : updateTarget != null + ? updateTarget.getRunDirectory() + : repository.getBaseDirectory(); + Path modsDirectory = runDirectory.resolve("mods"); + + Task libraryTask = dependencyManager.installUnpublishedComponentAsync( + initialManifest, modsDirectory, gameVersion, GameComponentType.GAME, gameVersion); + + for (Map.Entry entry : components.entrySet()) { + GameComponentType componentType = entry.getKey(); + if (componentType == GameComponentType.GAME) + continue; + + if (entry.getValue() instanceof ComponentRemoteVersion remoteVersion) { + libraryTask = libraryTask.thenComposeAsync(manifest -> + dependencyManager.installUnpublishedComponentAsync( + manifest, modsDirectory, remoteVersion)); + } else if (entry.getValue() instanceof String version) { + libraryTask = libraryTask.thenComposeAsync(manifest -> + dependencyManager.installUnpublishedComponentAsync( + manifest, modsDirectory, gameVersion, componentType, version)); + } else { + throw new AssertionError("Unexpected version type: " + entry.getValue().getClass()); + } } + + Task buildTask = libraryTask.thenComposeAsync(manifest -> { + GameInstanceManifest resolved = draft.getBaseSnapshot().resolve(manifest); + return new GameDownloadTask(dependencyManager, resolved) + .thenApplyAsync(minecraftJar -> { + draft.put(resolved.withPatches(manifest.patches())); + draft.putPrimaryJar(instanceId, minecraftJar); + DefaultGameInstance instance = draft.commit().getInstance(instanceId); + onInstanceCommitted(instance); + return instance; + }); + }) + .whenComplete(exception -> { + if (draft.isOpen()) { + draft.abort(); + } + }) + .withStagesHints(hints); + draftTransferred = true; + return buildTask; + } catch (RuntimeException | Error failure) { + DefaultDependencyManager.abortDraftAfterFailure(draft, failure); + throw failure; } + } - return libraryTask.thenComposeAsync(manifest -> { - GameInstanceManifest.Resolved resolved = draft.getBaseSnapshot().resolve(manifest); - return new GameDownloadTask(dependencyManager, resolved.launchManifest()) - .thenApplyAsync(minecraftJar -> { - draft.put(resolved.launchManifest().withPatches(manifest.patches())); - draft.putPrimaryJar(id, minecraftJar); - return draft.commit().getInstance(id); - }); - }) - .whenComplete(exception -> { - if (draft.isOpen()) { - draft.abort(); - } - }) - .withStagesHints(hints); + /// {@inheritDoc} + /// + /// @throws UncheckedIOException if reserved instance files cannot be cleaned up + @Override + public void close() { + if (draftTransferred || !draft.isOpen()) { + return; + } + + try { + draft.abort(); + } catch (IOException e) { + throw new UncheckedIOException("Cannot abort game builder draft", e); + } + } + + /// Performs repository-specific initialization after an instance is committed. + /// + /// This method is invoked after the repository has published the committed snapshot and before + /// the build task completes. The default implementation does nothing. An unchecked exception + /// prevents the task from completing successfully but does not roll back the committed instance. + /// + /// @param instance the committed instance from the published snapshot + protected void onInstanceCommitted(DefaultGameInstance instance) { + } + + /// {@inheritDoc} + @Override + protected final void checkOpen() { + if (draftTransferred) { + throw new IllegalStateException("GameBuilder has already created its build task"); + } + if (!draft.isOpen()) { + throw new IllegalStateException("GameBuilder is closed"); + } } } 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 c2a4890d284..67f5e2175b4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java @@ -19,6 +19,7 @@ import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstance; +import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameRepository; import org.jackhuang.hmcl.task.Task; @@ -70,15 +71,38 @@ public interface DependencyManager { /// @throws IllegalArgumentException if `instance` belongs to another repository Task checkPatchCompletionAsync(GameInstance instance, GameInstanceManifest manifest, boolean integrityCheck); - /// Creates a builder for installing a new game instance and optional loaders. + /// Creates a builder for installing a new game instance and optional components. /// + /// This operation opens an exclusive repository draft and reserves the target id immediately. + /// The returned builder must be closed if it is abandoned before [GameBuilder#buildAsync()]. A + /// synchronous failure from that method aborts the draft itself. + /// + /// @param instanceId the id of the new instance /// @return a new game builder - GameBuilder newGameBuilder(); + /// @throws IllegalStateException if the id is already registered, its root cannot be reserved, + /// or another repository draft is open + GameBuilder newGameBuilder(GameInstanceID instanceId); + + /// Creates a builder for replacing the components of an existing game instance. + /// + /// This operation opens an exclusive repository draft immediately. `instance` must be the exact + /// object in the current published snapshot; an instance invalidated by any intervening + /// repository publication is rejected. The returned builder must be closed if it is abandoned + /// before [GameBuilder#buildAsync()]; a synchronous failure from that method aborts the draft + /// itself. The resulting manifest is rebuilt from the configured components; components that + /// are not configured are not retained. + /// + /// @param instance the existing game instance to update + /// @return a game builder targeting the existing instance + /// @throws IllegalArgumentException if `instance` belongs to another repository + /// @throws IllegalStateException if `instance` is no longer the exact published instance or + /// another repository draft is open + GameBuilder newGameBuilder(GameInstance instance); /// 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); + ComponentVersionList 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 e29a30f4ae2..10a1347d899 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java @@ -66,7 +66,7 @@ default List injectURLsWithCandidates(List urls) { /// @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 getVersionList(GameComponentType componentType); + ComponentVersionList 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 45a74f47360..5570dbb1ad9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java @@ -69,8 +69,8 @@ public List injectURLsWithCandidates(List urls) { } @Override - public VersionList getVersionList(GameComponentType componentType) { - return new VersionList<>() { + public ComponentVersionList getVersionList(GameComponentType componentType) { + return new ComponentVersionList<>() { @Override public boolean hasType() { return getProvider().getVersionList(componentType).hasType(); 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 6b35aeb9645..da0642c93e9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java @@ -18,45 +18,84 @@ 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.*; +import java.util.EnumMap; -/// The builder which provide a task to build Minecraft environment. +/// Configures the components used to install or update a game instance. +/// +/// A builder owns an exclusive repository draft from construction until it is closed or transfers +/// that draft to the task returned by [#buildAsync()]. Builders are single-use and are not +/// thread-safe. /// /// @author huangyuhui @NotNullByDefault -public abstract class GameBuilder { +public abstract class GameBuilder implements AutoCloseable { - protected @Nullable GameInstanceID id; + /// Components to install, keyed by their manifest patch type. protected final EnumMap components = new EnumMap<>(GameComponentType.class); - /// The new game instance id, for `.minecraft/`. + /// Enables instance isolation for the built instance. /// - /// @param id the instance id of new game instance. - public GameBuilder id(GameInstanceID id) { - this.id = Objects.requireNonNull(id); - return this; - } + /// Component-provided run-directory files, such as loader-provided mods, are installed under + /// the instance root. The concrete builder must also ensure subsequent launches use that same + /// directory. + /// + /// @return this builder + /// @throws IllegalStateException if this builder is closed or has already created its build task + @Contract("-> this") + public abstract GameBuilder enableIsolation(); + /// Configures a component by its remote version id. + /// + /// Reconfiguring the same component type replaces its previous value. + /// + /// @param componentType the component type + /// @param version the remote version id + /// @return this builder + /// @throws IllegalStateException if this builder is closed or has already created its build task @Contract("_, _ -> this") public GameBuilder component(GameComponentType componentType, String version) { + checkOpen(); components.put(componentType, version); return this; } + /// Configures a component using an already resolved remote version. + /// + /// Reconfiguring the same component type replaces its previous value. + /// + /// @param remoteVersion the remote component version + /// @return this builder + /// @throws IllegalStateException if this builder is closed or has already created its build task @Contract("_ -> this") - public GameBuilder component(RemoteVersion remoteVersion) { + public GameBuilder component(ComponentRemoteVersion remoteVersion) { + checkOpen(); components.put(remoteVersion.getComponentType(), remoteVersion); return this; } - /** - * @return the task that can build the whole Minecraft environment - */ + /// Creates the task that installs the configured components and publishes the target instance. + /// + /// This operation may be invoked once. On success, ownership of the builder's exclusive + /// repository draft is transferred to the returned task. Closing the builder after that + /// transfer has no effect. If this method fails before returning a task, the draft is aborted. + /// + /// @return the instance build task public abstract Task buildAsync(); + + /// Abandons this builder and aborts its exclusive repository draft unless ownership has already + /// been transferred to a build task. + /// + /// This operation has no effect after a successful [#buildAsync()] call or after the builder has + /// already been closed. + @Override + public abstract void close(); + + /// Ensures this builder still accepts configuration or task creation. + /// + /// @throws IllegalStateException if this builder is closed or has already created its build task + protected abstract void checkOpen(); } 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 a2ecd9955a8..5e2ec3cac91 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java @@ -82,7 +82,7 @@ public List getAssetObjectCandidates(String assetObjectLocation) { } @Override - public VersionList getVersionList(GameComponentType componentType) { + public ComponentVersionList getVersionList(GameComponentType componentType) { return switch (componentType) { case GAME -> game; case FABRIC -> fabric; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MultipleSourceVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MultipleSourceVersionList.java deleted file mode 100644 index 89e3eb5dec4..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MultipleSourceVersionList.java +++ /dev/null @@ -1,113 +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.task.Task; - -import java.util.Arrays; -import java.util.Collection; -import java.util.List; - -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -public class MultipleSourceVersionList extends VersionList { - - private final VersionList[] backends; - - MultipleSourceVersionList(VersionList[] backends) { - this.backends = backends; - - assert (backends.length >= 1); - } - - @Override - public boolean hasType() { - boolean hasType = backends[0].hasType(); - assert (Arrays.stream(backends).allMatch(versionList -> versionList.hasType() == hasType)); - return hasType; - } - - @Override - public Task refreshAsync() { - throw new UnsupportedOperationException("MultipleSourceVersionList does not support loading the entire remote version list."); - } - - private Task refreshAsync(String gameVersion, int sourceIndex) { - VersionList versionList = backends[sourceIndex]; - Task refreshTask = versionList.refreshAsync(gameVersion); - - return new Task<>() { - private Task nextTask = null; - - { - setSignificance(TaskSignificance.MODERATE); - setName("MultipleSourceVersionList.refreshAsync(task=%s, index=%d, all=%d)".formatted( - refreshTask.getName(), sourceIndex, backends.length) - ); - } - - @Override - public Collection> getDependents() { - return List.of(refreshTask); - } - - @Override - public Collection> getDependencies() { - return nextTask != null ? List.of(nextTask) : List.of(); - } - - @Override - public boolean isRelyingOnDependents() { - return false; - } - - @Override - public void execute() throws Exception { - if (isDependentsSucceeded()) { - lock.writeLock().lock(); - try { - versions.putAll(gameVersion, versionList.getVersions(gameVersion)); - } finally { - lock.writeLock().unlock(); - } - - setResult(refreshTask.getResult()); - } else { - Exception exception = refreshTask.getException(); - assert exception != null; - - if (sourceIndex == backends.length - 1) { - LOG.warning("Failed to fetch versions list from all sources", exception); - setSignificance(TaskSignificance.MINOR); - throw exception; - } else { - LOG.warning("Failed to fetch versions list and try to fetch from other source", exception); - nextTask = refreshAsync(gameVersion, sourceIndex + 1); - nextTask.storeTo(this::setResult); - } - } - } - }; - } - - @Override - public Task refreshAsync(String gameVersion) { - versions.clear(gameVersion); - return refreshAsync(gameVersion, 0); - } -} 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 c3050243757..209809d93c7 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 @@ -180,9 +180,7 @@ public static Task install( String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); if (GameComponentType.CLEANROOM.getPatchId().equals(installProfile.get("profile"))) { - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze( - dependencyManager.getGameRepository().resolve(manifest), - GameVersionNumber.asGameVersion(gameVersion)); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, GameVersionNumber.asGameVersion(gameVersion)); if (analyzer.has(GameComponentType.FORGE)) { throw new UnsupportedInstallationException(CLEANROOM_NOT_COMPATIBLE_WITH_FORGE); } 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 e7a3d073922..04c0b12ed94 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,22 +18,23 @@ package org.jackhuang.hmcl.download.cleanroom; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; 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 java.nio.file.Path; import java.time.Instant; import java.util.List; -public class CleanroomRemoteVersion extends RemoteVersion { +public class CleanroomRemoteVersion extends ComponentRemoteVersion { public CleanroomRemoteVersion(String gameVersion, String selfVersion, Instant releaseDate, List url) { super(GameComponentType.CLEANROOM, gameVersion, selfVersion, releaseDate, url); } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new CleanroomInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseManifest, Path modsDirectory) { + return new CleanroomInstallTask(dependencyManager, baseManifest, this); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomVersionList.java index 6495a130bcf..62ec8848b8b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomVersionList.java @@ -18,14 +18,14 @@ package org.jackhuang.hmcl.download.cleanroom; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import java.time.Instant; import java.util.Collections; -public final class CleanroomVersionList extends VersionList { +public final class CleanroomVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; private static final String LOADER_LIST_URL = "https://hmcl.glavo.site/metadata/cleanroom/index.json"; private static final String INSTALLER_URL = "https://hmcl.glavo.site/metadata/cleanroom/files/cleanroom-%s-installer.jar"; 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 03b287370cb..2d923f0130a 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,7 +18,7 @@ package org.jackhuang.hmcl.download.fabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; @@ -29,7 +29,7 @@ import java.time.Instant; import java.util.List; -public class FabricAPIRemoteVersion extends RemoteVersion { +public class FabricAPIRemoteVersion extends ComponentRemoteVersion { private final String fullVersion; private final RemoteAddon.Version version; @@ -59,13 +59,13 @@ public RemoteAddon.Version getVersion() { @Override public Task getInstallTask( DefaultDependencyManager dependencyManager, - GameInstanceManifest baseVersion, + GameInstanceManifest baseManifest, Path modsDirectory) { - return new FabricAPIInstallTask(dependencyManager, baseVersion, this, modsDirectory); + return new FabricAPIInstallTask(dependencyManager, baseManifest, this, modsDirectory); } @Override - public int compareTo(RemoteVersion o) { + public int compareTo(ComponentRemoteVersion o) { if (!(o instanceof FabricAPIRemoteVersion)) return 0; return -this.getReleaseDate().compareTo(o.getReleaseDate()); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIVersionList.java index 623240a2001..83fc136114b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.fabric; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; import org.jackhuang.hmcl.task.Task; @@ -26,7 +26,7 @@ import java.util.Collections; -public class FabricAPIVersionList extends VersionList { +public class FabricAPIVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; 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 090596ede18..6a89cab8086 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 @@ -47,6 +47,10 @@ public final class FabricInstallTask extends Task { private final List> dependencies = new ArrayList<>(1); public FabricInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, FabricRemoteVersion remoteVersion) { + if (!manifest.isModifiable()) { + throw new IllegalArgumentException("Manifest is not modifiable"); + } + this.dependencyManager = dependencyManager; this.manifest = manifest; this.remote = remoteVersion; @@ -62,7 +66,7 @@ public boolean doPreExecute() { @Override public void preExecute() throws Exception { - if (!Objects.equals(GameComponentAnalyzer.VANILLA_MAIN, dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass())) + if (!Objects.equals(GameComponentAnalyzer.VANILLA_MAIN, manifest.mainClass())) throw new UnsupportedInstallationException(FABRIC_NOT_COMPATIBLE_WITH_FORGE); } 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 a95fdd8ab1b..9b1bfd896b2 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,15 +18,16 @@ package org.jackhuang.hmcl.download.fabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; 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 java.nio.file.Path; import java.util.List; -public class FabricRemoteVersion extends RemoteVersion { +public class FabricRemoteVersion extends ComponentRemoteVersion { /** * Constructor. * @@ -39,7 +40,7 @@ public class FabricRemoteVersion extends RemoteVersion { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new FabricInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseManifest, Path modsDirectory) { + return new FabricInstallTask(dependencyManager, baseManifest, this); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricVersionList.java index ed2e10b3a5f..9a3c7714308 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.fabric; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -31,7 +31,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf; -public final class FabricVersionList extends VersionList { +public final class FabricVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; public FabricVersionList(DownloadProvider downloadProvider) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeBMCLVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeBMCLVersionList.java index 1f06fe7d89d..d9aae6a7c97 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeBMCLVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeBMCLVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.forge; import com.google.gson.JsonParseException; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Immutable; @@ -37,7 +37,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class ForgeBMCLVersionList extends VersionList { +public final class ForgeBMCLVersionList extends ComponentVersionList { private final String apiRoot; /** 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 8b6e089659f..35e6b129d24 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 @@ -56,6 +56,10 @@ public final class ForgeInstallTask extends Task { private Task dependency; public ForgeInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, ForgeRemoteVersion remoteVersion) { + if (!manifest.isModifiable()) { + throw new IllegalArgumentException("Manifest is not modifiable"); + } + this.dependencyManager = dependencyManager; this.manifest = manifest; this.remote = remoteVersion; @@ -102,7 +106,7 @@ public Collection> getDependencies() { @Override public void execute() throws IOException, VersionMismatchException, UnsupportedInstallationException { - String originalMainClass = dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass(); + String originalMainClass = manifest.mainClass(); if (GameVersionNumber.compare("1.13", remote.getGameVersion()) <= 0) { // Forge 1.13 is not compatible with fabric. if (!GameComponentAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) @@ -173,7 +177,7 @@ public static Task install( String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); if (installProfile.containsKey("spec")) { - checkCleanroomCompatibility(dependencyManager, manifest, gameVersion); + checkCleanroomCompatibility(manifest, gameVersion); ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion); @@ -185,7 +189,7 @@ public static Task install( modifyVersion(gameVersion, profile.getVersion()), installer)); } else if (installProfile.containsKey("install") && installProfile.containsKey("versionInfo")) { - checkCleanroomCompatibility(dependencyManager, manifest, gameVersion); + checkCleanroomCompatibility(manifest, gameVersion); ForgeInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeInstallProfile.class); if (!gameVersion.equals(profile.install().getMinecraft())) throw new VersionMismatchException(profile.install().getMinecraft(), gameVersion); @@ -198,17 +202,13 @@ public static Task install( /// Rejects Forge installation when the manifest already contains Cleanroom. /// - /// @param dependencyManager repository-scoped download services /// @param manifest working manifest receiving the Forge patch /// @param gameVersion Minecraft version used for component analysis /// @throws UnsupportedInstallationException if the manifest already contains Cleanroom private static void checkCleanroomCompatibility( - DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, String gameVersion) throws UnsupportedInstallationException { - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze( - dependencyManager.getGameRepository().resolve(manifest), - GameVersionNumber.asGameVersion(gameVersion)); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, GameVersionNumber.asGameVersion(gameVersion)); if (analyzer.has(GameComponentType.CLEANROOM)) { throw new UnsupportedInstallationException(CLEANROOM_NOT_COMPATIBLE_WITH_FORGE); } 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 f93607092c0..14f3514075c 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,16 +18,17 @@ package org.jackhuang.hmcl.download.forge; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; 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 java.nio.file.Path; import java.time.Instant; import java.util.List; -public class ForgeRemoteVersion extends RemoteVersion { +public class ForgeRemoteVersion extends ComponentRemoteVersion { /** * Constructor. * @@ -40,7 +41,7 @@ public ForgeRemoteVersion(String gameVersion, String selfVersion, Instant releas } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new ForgeInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseManifest, Path modsDirectory) { + return new ForgeInstallTask(dependencyManager, baseManifest, this); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeVersionList.java index 9d0941b2b2b..be63c853989 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.forge; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.StringUtils; @@ -33,7 +33,7 @@ * * @author huangyuhui */ -public final class ForgeVersionList extends VersionList { +public final class ForgeVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; public ForgeVersionList(DownloadProvider downloadProvider) { 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 01be3d36359..fc849978432 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 @@ -55,7 +55,7 @@ public final class GameAssetDownloadTask extends Task { /// @param manifest the game version public GameAssetDownloadTask(AbstractDependencyManager dependencyManager, GameInstanceManifest manifest, boolean forceDownloadingIndex, boolean integrityCheck) { this.dependencyManager = dependencyManager; - this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); + this.manifest = manifest; this.assetIndexInfo = this.manifest.getAssetIndex(); GameRepository gameRepository = dependencyManager.getGameRepository(); String assetId = assetIndexInfo.getId(); 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 6709cb5c3b6..44dde356245 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 @@ -53,7 +53,7 @@ public GameDownloadTask( DefaultDependencyManager dependencyManager, GameInstanceManifest manifest) { this.dependencyManager = dependencyManager; - this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); + this.manifest = manifest; setSignificance(TaskSignificance.MODERATE); } 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 0ca46588df3..527371fd28b 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 @@ -93,7 +93,7 @@ public void execute() throws Exception { GameInstancePatch.PRIORITY_MC).withJar(null); setResult(patch); - GameInstanceManifest newManifest = new GameInstanceManifest(this.manifest.id()).addPatch(patch); + GameInstanceManifest newManifest = new GameInstanceManifest(this.manifest.id()).addPatch(patch).reconstructByPatches(); dependencies.add(Task.allOf( new GameDownloadTask(dependencyManager, newManifest), Task.allOf( 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 d3c37a8d66e..9d815529a15 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 @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; @@ -38,7 +38,7 @@ public final class GameInstanceJsonDownloadTask extends Task { private final DefaultDependencyManager dependencyManager; private final List> dependents = new ArrayList<>(1); private final List> dependencies = new ArrayList<>(1); - private final VersionList gameVersionList; + private final ComponentVersionList gameVersionList; public GameInstanceJsonDownloadTask(String gameVersion, DefaultDependencyManager dependencyManager) { this.gameVersion = gameVersion; @@ -62,7 +62,7 @@ public Collection> getDependents() { @Override public void execute() throws IOException { - RemoteVersion remoteVersion = gameVersionList.getVersion(gameVersion, gameVersion) + ComponentRemoteVersion remoteVersion = gameVersionList.getVersion(gameVersion, gameVersion) .orElseThrow(() -> new IOException("Cannot find specific version " + gameVersion + " in remote repository")); dependencies.add(new GetTask(dependencyManager.getDownloadProvider().injectURLsWithCandidates(remoteVersion.getUrls())).storeTo(this::setResult)); } 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 6d817040d72..fcd6e3ea716 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 @@ -62,7 +62,7 @@ public final class GameLibrariesTask extends Task { * @param manifest the game version */ public GameLibrariesTask(AbstractDependencyManager dependencyManager, GameInstanceManifest manifest, boolean integrityCheck) { - this(dependencyManager, manifest, integrityCheck, dependencyManager.getGameRepository().resolve(manifest).launchManifest().getLibraries()); + this(dependencyManager, manifest, integrityCheck, manifest.getLibraries()); } /** 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 5d3dcfc68df..aad2e072ea5 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,7 +18,7 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; @@ -27,6 +27,7 @@ import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import java.nio.file.Path; import java.time.Instant; import java.util.List; @@ -35,7 +36,7 @@ * @author huangyuhui */ @Immutable -public final class GameRemoteVersion extends RemoteVersion { +public final class GameRemoteVersion extends ComponentRemoteVersion { private final ReleaseType type; @@ -49,12 +50,12 @@ public ReleaseType getType() { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new GameInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseManifest, Path modsDirectory) { + return new GameInstallTask(dependencyManager, baseManifest, this); } @Override - public int compareTo(RemoteVersion o) { + public int compareTo(ComponentRemoteVersion o) { if (!(o instanceof GameRemoteVersion)) { return 0; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVersionList.java index cf8428b8044..3ef8fe399f8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -34,7 +34,7 @@ * * @author huangyuhui */ -public final class GameVersionList extends VersionList { +public final class GameVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; public GameVersionList(DownloadProvider downloadProvider) { 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 c93ed578175..3d116624471 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,7 +18,7 @@ package org.jackhuang.hmcl.download.legacyfabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; @@ -29,7 +29,7 @@ import java.time.Instant; import java.util.List; -public class LegacyFabricAPIRemoteVersion extends RemoteVersion { +public class LegacyFabricAPIRemoteVersion extends ComponentRemoteVersion { private final String fullVersion; private final RemoteAddon.Version version; @@ -59,13 +59,13 @@ public RemoteAddon.Version getVersion() { @Override public Task getInstallTask( DefaultDependencyManager dependencyManager, - GameInstanceManifest baseVersion, + GameInstanceManifest baseManifest, Path modsDirectory) { - return new LegacyFabricAPIInstallTask(dependencyManager, baseVersion, this, modsDirectory); + return new LegacyFabricAPIInstallTask(dependencyManager, baseManifest, this, modsDirectory); } @Override - public int compareTo(RemoteVersion o) { + public int compareTo(ComponentRemoteVersion o) { if (!(o instanceof LegacyFabricAPIRemoteVersion)) return 0; return -this.getReleaseDate().compareTo(o.getReleaseDate()); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIVersionList.java index 9c5a9408170..9f9edb5fcd6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.legacyfabric; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; import org.jackhuang.hmcl.task.Task; @@ -26,7 +26,7 @@ import java.util.Collections; -public class LegacyFabricAPIVersionList extends VersionList { +public class LegacyFabricAPIVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; 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 efb24bee0de..ae7aabc176c 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,15 +18,16 @@ package org.jackhuang.hmcl.download.legacyfabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; 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 java.nio.file.Path; import java.util.List; -public class LegacyFabricRemoteVersion extends RemoteVersion { +public class LegacyFabricRemoteVersion extends ComponentRemoteVersion { /** * Constructor. * @@ -39,7 +40,7 @@ public class LegacyFabricRemoteVersion extends RemoteVersion { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new LegacyFabricInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseManifest, Path modsDirectory) { + return new LegacyFabricInstallTask(dependencyManager, baseManifest, this); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricVersionList.java index 272f82f8cda..a6c336620f8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.legacyfabric; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -31,7 +31,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf; -public final class LegacyFabricVersionList extends VersionList { +public final class LegacyFabricVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; public LegacyFabricVersionList(DownloadProvider downloadProvider) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderBMCLVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderBMCLVersionList.java index c80aa47a956..52d67371dc9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderBMCLVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderBMCLVersionList.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.liteloader; import org.jackhuang.hmcl.download.BMCLAPIDownloadProvider; -import org.jackhuang.hmcl.download.RemoteVersion; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -31,7 +31,7 @@ /** * @author huangyuhui */ -public final class LiteLoaderBMCLVersionList extends VersionList { +public final class LiteLoaderBMCLVersionList extends ComponentVersionList { private final BMCLAPIDownloadProvider downloadProvider; public LiteLoaderBMCLVersionList(BMCLAPIDownloadProvider downloadProvider) { @@ -73,7 +73,7 @@ public Task refreshAsync(String gameVersion) { if (v == null) return; versions.put(gameVersion, new LiteLoaderRemoteVersion( - gameVersion, v.version, RemoteVersion.Type.UNCATEGORIZED, + gameVersion, v.version, ComponentRemoteVersion.Type.UNCATEGORIZED, Collections.singletonList(NetworkUtils.withQuery( downloadProvider.getApiRoot() + "/liteloader/download", Collections.singletonMap("version", v.version) 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 f6a09e79b8e..b7f624b0741 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,17 +18,18 @@ package org.jackhuang.hmcl.download.liteloader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.game.Library; import org.jackhuang.hmcl.task.Task; +import java.nio.file.Path; import java.util.Collection; import java.util.List; -public class LiteLoaderRemoteVersion extends RemoteVersion { +public class LiteLoaderRemoteVersion extends ComponentRemoteVersion { private final String tweakClass; private final Collection libraries; @@ -55,7 +56,7 @@ public String getTweakClass() { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new LiteLoaderInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseManifest, Path modsDirectory) { + return new LiteLoaderInstallTask(dependencyManager, baseManifest, this); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderVersionList.java index dffb8cd2eb4..a5c36982f98 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderVersionList.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.liteloader; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.RemoteVersion; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.HttpRequest; @@ -35,7 +35,7 @@ /** * @author huangyuhui */ -public final class LiteLoaderVersionList extends VersionList { +public final class LiteLoaderVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; @@ -99,7 +99,7 @@ private void loadArtifactVersion(String gameVersion, LiteLoaderRepository reposi continue; versions.put(gameVersion, new LiteLoaderRemoteVersion( - gameVersion, v.getVersion(), RemoteVersion.Type.RELEASE, + gameVersion, v.getVersion(), ComponentRemoteVersion.Type.RELEASE, Collections.singletonList(repository.getUrl() + "com/mumfrey/liteloader/" + gameVersion + "/" + v.getFile()), v.getTweakClass(), v.getLibraries() )); @@ -117,7 +117,7 @@ private LiteLoaderRemoteVersion loadSnapshotVersion(String gameVersion, LiteLoad String buildNumber = Objects.requireNonNull(document.select("buildNumber"), "buildNumber").text(); return new LiteLoaderRemoteVersion( - gameVersion, timestamp + "-" + buildNumber, RemoteVersion.Type.SNAPSHOT, + gameVersion, timestamp + "-" + buildNumber, ComponentRemoteVersion.Type.SNAPSHOT, Collections.singletonList(String.format(SNAPSHOT_FILE, gameVersion, gameVersion, timestamp, buildNumber)), v.getTweakClass(), v.getLibraries() ); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeBMCLVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeBMCLVersionList.java index d0466fd5208..00b5c5a992a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeBMCLVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeBMCLVersionList.java @@ -19,7 +19,7 @@ import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Immutable; @@ -30,7 +30,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf; -public final class NeoForgeBMCLVersionList extends VersionList { +public final class NeoForgeBMCLVersionList extends ComponentVersionList { private final String apiRoot; /** diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOfficialVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOfficialVersionList.java index a7cab8ddd2f..230851e01ec 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOfficialVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOfficialVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.neoforge; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonSerializable; @@ -29,7 +29,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class NeoForgeOfficialVersionList extends VersionList { +public final class NeoForgeOfficialVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; public NeoForgeOfficialVersionList(DownloadProvider downloadProvider) { 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 4b3483f9610..f30d9b18a6f 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,22 +18,23 @@ package org.jackhuang.hmcl.download.neoforge; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; 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 java.nio.file.Path; import java.util.List; -public class NeoForgeRemoteVersion extends RemoteVersion { +public class NeoForgeRemoteVersion extends ComponentRemoteVersion { public NeoForgeRemoteVersion(String gameVersion, String selfVersion, List urls) { super(GameComponentType.NEO_FORGE, gameVersion, selfVersion, null, getType(selfVersion), urls); } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new NeoForgeInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseManifest, Path modsDirectory) { + return new NeoForgeInstallTask(dependencyManager, baseManifest, this); } private static Type getType(String version) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineBMCLVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineBMCLVersionList.java index 608a1ca0470..4ec3b8d1936 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineBMCLVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineBMCLVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.optifine; import com.google.gson.annotations.SerializedName; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.StringUtils; @@ -33,7 +33,7 @@ /** * @author huangyuhui */ -public final class OptiFineBMCLVersionList extends VersionList { +public final class OptiFineBMCLVersionList extends ComponentVersionList { private final String apiRoot; /** 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 10f1c194486..39f3d6d2eb9 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 @@ -154,7 +154,7 @@ public void execute() throws Exception { throw new IOException("Minecraft client JAR not found: " + minecraftJar); } Path installerFile = Objects.requireNonNull(dest); - String originalMainClass = dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass(); + String originalMainClass = manifest.mainClass(); if (!GameComponentAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) throw new UnsupportedInstallationException(UnsupportedInstallationException.UNSUPPORTED_LAUNCH_WRAPPER); @@ -253,7 +253,7 @@ public void execute() throws Exception { /// 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 manifest 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 @@ -261,7 +261,7 @@ public void execute() throws Exception { /// @throws VersionMismatchException if the installer targets another Minecraft version public static Task install( DefaultDependencyManager dependencyManager, - GameInstanceManifest version, + GameInstanceManifest manifest, String gameVersion, Path installer) throws IOException, VersionMismatchException { try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { @@ -287,10 +287,10 @@ public static Task install( ofEdition + "_" + ofRelease, Collections.singletonList(""), false); - return new GameDownloadTask(dependencyManager, version) + return new GameDownloadTask(dependencyManager, manifest) .thenComposeAsync(minecraftJar -> new OptiFineInstallTask( dependencyManager, - version, + manifest, 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 b7b788b8ea3..7cec29d4f03 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,16 +18,17 @@ package org.jackhuang.hmcl.download.optifine; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; 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; +import java.nio.file.Path; import java.util.List; -public class OptiFineRemoteVersion extends RemoteVersion { +public class OptiFineRemoteVersion extends ComponentRemoteVersion { public OptiFineRemoteVersion(String gameVersion, String selfVersion, List urls, boolean snapshot) { super(GameComponentType.OPTIFINE, gameVersion, selfVersion, null, snapshot ? Type.SNAPSHOT : Type.RELEASE, urls); @@ -39,11 +40,11 @@ public String getFullVersion() { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new GameDownloadTask(dependencyManager, baseVersion) + public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseManifest, Path modsDirectory) { + return new GameDownloadTask(dependencyManager, baseManifest) .thenComposeAsync(minecraftJar -> new OptiFineInstallTask( dependencyManager, - baseVersion, + baseManifest, this, minecraftJar)); } 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 96d46ef9a87..cf238420c58 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,7 +18,7 @@ package org.jackhuang.hmcl.download.quilt; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; @@ -29,7 +29,7 @@ import java.time.Instant; import java.util.List; -public class QuiltAPIRemoteVersion extends RemoteVersion { +public class QuiltAPIRemoteVersion extends ComponentRemoteVersion { private final String fullVersion; private final RemoteAddon.Version version; @@ -59,13 +59,13 @@ public RemoteAddon.Version getVersion() { @Override public Task getInstallTask( DefaultDependencyManager dependencyManager, - GameInstanceManifest baseVersion, + GameInstanceManifest baseManifest, Path modsDirectory) { - return new QuiltAPIInstallTask(dependencyManager, baseVersion, this, modsDirectory); + return new QuiltAPIInstallTask(dependencyManager, baseManifest, this, modsDirectory); } @Override - public int compareTo(RemoteVersion o) { + public int compareTo(ComponentRemoteVersion o) { if (!(o instanceof QuiltAPIRemoteVersion)) return 0; return -this.getReleaseDate().compareTo(o.getReleaseDate()); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIVersionList.java index 28a2301e734..e5217359384 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.quilt; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; import org.jackhuang.hmcl.task.Task; @@ -26,7 +26,7 @@ import java.util.Collections; -public class QuiltAPIVersionList extends VersionList { +public class QuiltAPIVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; 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 e747533e098..bc94a6cb79e 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 @@ -61,7 +61,7 @@ public boolean doPreExecute() { @Override public void preExecute() throws Exception { - if (!Objects.equals("net.minecraft.client.main.Main", dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass())) + if (!Objects.equals(GameComponentAnalyzer.VANILLA_MAIN, manifest.mainClass())) throw new UnsupportedInstallationException(FABRIC_NOT_COMPATIBLE_WITH_FORGE); } 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 4385fb39077..2373730bc50 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,15 +18,16 @@ package org.jackhuang.hmcl.download.quilt; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.ComponentRemoteVersion; 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 java.nio.file.Path; import java.util.List; -public class QuiltRemoteVersion extends RemoteVersion { +public class QuiltRemoteVersion extends ComponentRemoteVersion { /** * Constructor. * @@ -39,7 +40,7 @@ public class QuiltRemoteVersion extends RemoteVersion { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new QuiltInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseManifest, Path modsDirectory) { + return new QuiltInstallTask(dependencyManager, baseManifest, this); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltVersionList.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltVersionList.java index b007d54372d..879f5957e96 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltVersionList.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltVersionList.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download.quilt; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.download.ComponentVersionList; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -31,7 +31,7 @@ import static org.jackhuang.hmcl.util.gson.JsonUtils.listTypeOf; -public final class QuiltVersionList extends VersionList { +public final class QuiltVersionList extends ComponentVersionList { private final DownloadProvider downloadProvider; public QuiltVersionList(DownloadProvider downloadProvider) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java index 87a3a867424..041467d669b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -57,7 +57,7 @@ public abstract class DefaultGameInstance implements GameInstance { /// `.jar` extension. protected final @Nullable Path manifestFile; - protected GameInstanceManifest.@Nullable Resolved resolvedManifest; + protected @Nullable GameInstanceManifest resolvedManifest; private @Nullable GameComponentAnalyzer analyzer; @@ -158,7 +158,7 @@ public GameInstanceManifest getManifest() { } @Override - public GameInstanceManifest.Resolved getResolvedManifest() { + public GameInstanceManifest getResolvedManifest() { if (resolvedManifest == null) { resolvedManifest = snapshot.resolve(manifest); } @@ -168,7 +168,7 @@ public GameInstanceManifest.Resolved getResolvedManifest() { @Override public GameComponentAnalyzer getAnalyzer() { if (analyzer == null) { - analyzer = GameComponentAnalyzer.analyze(getResolvedManifest(), getVersion()); + analyzer = GameComponentAnalyzer.analyze(manifest.isModifiable() ? manifest : getResolvedManifest(), getVersion()); } return analyzer; } @@ -261,7 +261,7 @@ public Path getModpackConfigurationFile() { /// instance's own jar is returned from [#getOwnJarFile()]. @Override public Path getInstanceJarFile() { - GameInstanceManifest launchManifest = getResolvedManifest().launchManifest(); + GameInstanceManifest launchManifest = getResolvedManifest(); GameInstanceID jarId = Optional.ofNullable(launchManifest.jar()).orElse(launchManifest.id()); if (!jarId.equals(id)) { DefaultGameInstance other = snapshot.findInstance(jarId); 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 d24a7655b9e..fddbe956849 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -27,7 +27,6 @@ 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.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -116,31 +115,6 @@ public DefaultGameRepository(Path baseDirectory) { /// @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 { - } - /// Prepares subclass-managed writes before instance files are moved or removed. /// /// The default implementation does nothing. @@ -194,7 +168,6 @@ public ReadOnlyObjectProperty snapshotP protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { newSnapshot.seal(); runOnFxThreadAndWait(() -> { - snapshot.set(newSnapshot); }); } @@ -442,25 +415,6 @@ public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInsta return getSnapshot().getRegistered(id); } - /// 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()); - DefaultGameInstance instance = findSnapshotInstance(id); - if (instance != null) { - return instance.getOwnJarFile(); - } - return getLayout().getInstanceJarFile(id); - } - @Override public boolean renameInstance(GameInstanceID from, GameInstanceID to) { try (DefaultGameRepositoryDraft draft = openDraft()) { @@ -533,58 +487,6 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { } } - @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); - 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(); - } - } - - /// 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) { - DefaultGameInstance instance = findSnapshotInstance(instanceId); - if (instance != null) { - return instance.getManifestFile(); - } - return getLayout().getInstanceJson(instanceId); - } - - /// 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 @@ -627,44 +529,49 @@ public Task saveAsync(GameInstanceManifest instanceManifes /// 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 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(() -> { - GameRepositoryDraft draft = openDraft(); - active.set(draft); - return draft.getBaseSnapshot().getInstance(instanceId); - }) - .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(); - } - }); + return Task.composeAsync(() -> { + DefaultGameRepositoryDraft draft = openDraft(); + try { + return Objects.requireNonNull( + updater.apply(draft.getBaseSnapshot().getInstance(instanceId)), + "Instance updater returned null") + .thenApplyAsync(manifest -> { + 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 -> { + if (draft.isOpen()) { + draft.abort(); + } + }); + } catch (Throwable exception) { + abortDraftAfterFailure(draft, exception); + throw exception; + } + }); } - @Override - public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { - return getSnapshot().resolve(manifest); + /// Aborts a draft after task construction fails and preserves any cleanup failure. + /// + /// @param draft the draft to abort + /// @param failure the failure that prevented task construction + private static void abortDraftAfterFailure(DefaultGameRepositoryDraft draft, Throwable failure) { + try { + draft.abort(); + } catch (Exception cleanupFailure) { + failure.addSuppressed(cleanupFailure); + } } /// Creates an empty unsealed snapshot for the given layout. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java index 48ccdf73327..9ef4b22d6d1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -218,7 +218,7 @@ private void putManifest( Path root = baseSnapshot.getLayout().getInstanceRoot(id) .toAbsolutePath() .normalize(); - if (!repository.mayClaimDraftInstanceRoot(id, root)) { + if (!Files.notExists(root)) { throw new FileAlreadyExistsException(root.toString(), null, "An unregistered instance directory already exists"); } @@ -365,9 +365,9 @@ private DefaultGameRepositorySnapshot buildCommittedSnapshot() { return committedSnapshot; } - /// Creates and initializes roots reserved for instances added by this draft. + /// Creates roots reserved for instances added by this draft. /// - /// @throws IOException if a root or repository-specific initial data cannot be created + /// @throws IOException if a root cannot be created private void materializeCreatedInstanceRoots() throws IOException { for (GameInstanceID id : createdIds) { if (!manifests.containsKey(id)) { @@ -377,7 +377,6 @@ private void materializeCreatedInstanceRoots() throws IOException { .toAbsolutePath() .normalize(); Files.createDirectories(root); - repository.initializeDraftInstanceRoot(id, root); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java index 93cfe166b35..2a7cfea471d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -17,9 +17,6 @@ */ 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; @@ -176,24 +173,8 @@ DefaultGameRepositorySnapshot mutableCopy() { 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; + public GameInstanceManifest resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { + return resolve(manifest, new HashSet<>()); } /// Resolves official-layout inheritance and patches without launch-library deduplication. @@ -202,31 +183,29 @@ public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) thro /// @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( + private GameInstanceManifest 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()); + @Nullable Set resolvedSoFar) throws NoSuchGameInstanceException { + GameInstanceManifest resolvedManifest; 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 + resolvedManifest = manifest.patches() != null ? new GameInstanceManifest(manifest.id()).withPatches(manifest.patches()) : manifest; } else { - launchManifest = manifest; + resolvedManifest = manifest; } - launchManifest = launchManifest.withJar(manifest.jar() == null ? manifest.id() : manifest.jar()); + resolvedManifest = resolvedManifest.withJar(manifest.jar() == null ? manifest.id() : manifest.jar()); } else { + if (resolvedSoFar == null) { + resolvedSoFar = new HashSet<>(); + } + // 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) + resolvedManifest = (manifest.jar() == null ? manifest.withJar(manifest.id()) : manifest) .withInheritsFrom(null); } else { DefaultGameInstance parentInstance = instances.get(manifest.inheritsFrom()); @@ -235,117 +214,26 @@ private GameInstanceManifest.Resolved resolve( } // 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()); + GameInstanceManifest parentResolved = resolve(parentInstance.getManifest(), resolvedSoFar); + resolvedManifest = manifest.merge(parentResolved); } } + var builder = new GameInstanceManifest.Builder(resolvedManifest); + 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); + builder.merge(patch); } } - return libraries.size() == manifest.getLibraries().size() - ? manifest - : manifest.withLibraries(libraries); + builder.setId(manifest.id()); + builder.setPatches(null); + return builder.toManifest(); } - 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 index 2aa2ba16d23..ea91f49e031 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -26,6 +26,7 @@ import org.jetbrains.annotations.Unmodifiable; import java.util.*; +import java.util.regex.Pattern; @NotNullByDefault public final class GameComponentAnalyzer implements Iterable { @@ -69,13 +70,9 @@ private static GameComponentAnalyzer analyze( 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"); + throw new IllegalArgumentException("GameComponentAnalyzer can only analyze independent game version"); return analyze(manifest, manifest, gameVersion); } @@ -103,49 +100,14 @@ public boolean has(ModLoaderType type) { return false; } - public boolean hasModLauncher() { + public boolean hasForgeModLauncher() { 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 Mark getMark(GameComponentType type) { + return components.get(type); } public @Nullable String getVersion(GameComponentType type) { @@ -157,11 +119,25 @@ public GameInstanceManifest removeLibrary(GameComponentType componentType) { return bootstrapVersion; } + public boolean isModded() { + String mainClass = manifest.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; + } + /// 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()); + return manifest.hasPatch(type); } @Override @@ -169,13 +145,6 @@ 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, @@ -220,4 +189,6 @@ public record Mark( "optifine.OptiFineForgeTweaker" ); public static final String LITELOADER_TWEAKER = "com.mumfrey.liteloader.launch.LiteLoaderTweaker"; + + public static final Pattern OPTIFINE_VERSION_PATTERN = Pattern.compile("^([0-9.]+)_(?HD_.+)$"); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java index 85258c4b1f9..a558a8792b0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -53,17 +53,10 @@ public interface GameInstance { /// @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(); - } + GameInstanceManifest getResolvedManifest(); GameComponentAnalyzer getAnalyzer(); 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 7ad8706099c..67d0237dd83 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java @@ -63,51 +63,6 @@ public record GameInstanceManifest( @Nullable @Unmodifiable JsonObject rawJson ) { - /// Resolved manifest views with inheritance folded. - /// - /// @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, - GameInstanceManifest standaloneManifest) { - - /// Creates a resolved manifest view. - public Resolved { - Objects.requireNonNull(launchManifest); - Objects.requireNonNull(standaloneManifest); - - if (!launchManifest.id().equals(standaloneManifest.id())) { - throw new IllegalArgumentException("Resolved manifest views must have the same id"); - } - - if (launchManifest.inheritsFrom() != null) { - throw new IllegalArgumentException("Launch manifest cannot inherit from another manifest"); - } - if (launchManifest.patches() != null && !launchManifest.patches().isEmpty()) { - throw new IllegalArgumentException("Launch manifest cannot contain pending patches"); - } - if (standaloneManifest.inheritsFrom() != null) { - 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) { return new GameInstanceManifest( id, @@ -355,6 +310,10 @@ public boolean isRoot() { return root != null && root; } + public boolean isModifiable() { + return inheritsFrom == null && patches != null && hasPatch(GameComponentType.GAME); + } + /// Returns the pending patches. /// /// @return the pending patches, or an empty list when absent @@ -362,6 +321,20 @@ public List getPatches() { return patches == null ? List.of() : patches; } + /// Finds a patch by its id. + /// + /// @return the patch with the given id, or `null` if no such patch exists. + public @Nullable GameInstancePatch findPatch(GameComponentType type) { + if (patches != null) { + for (GameInstancePatch patch : patches) { + if (type.getPatchId().equals(patch.id())) { + return patch; + } + } + } + return null; + } + /// Returns logging metadata. /// /// @return logging metadata @@ -565,18 +538,9 @@ public GameInstanceManifest withPatches(@Nullable List patche /// Returns a manifest copy with additional patches. public GameInstanceManifest addPatch(GameInstancePatch additional) { - return addPatches(List.of(additional)); - } - - /// Returns a manifest copy with additional patches. - public GameInstanceManifest addPatches(@Nullable List additional) { Set patchIds = new HashSet<>(); - if (additional != null) { - for (GameInstancePatch patch : additional) { - if (patch.id() != null) { - patchIds.add(patch.id()); - } - } + if (additional.id() != null) { + patchIds.add(additional.id()); } List patches = new ArrayList<>(); @@ -587,15 +551,13 @@ public GameInstanceManifest addPatches(@Nullable List additio } } } - if (additional != null) { - patches.addAll(additional); - } + patches.add(additional); return withPatches(patches); } /// Returns whether this manifest has a patch with the given id. - public boolean hasPatch(String patchId) { - return patches != null && patches.stream().anyMatch(patch -> patchId.equals(patch.id())); + public boolean hasPatch(GameComponentType type) { + return findPatch(type) != null; } /// Converts this manifest into a hidden patch entry for preserving resolved inheritance. @@ -694,7 +656,39 @@ public JsonObject toJsonObject() { return json; } - private static final class Builder { + public GameInstanceManifest removeComponent(GameComponentType type) { + @Nullable GameInstancePatch patch = findPatch(type); + if (patch == null) { + return this; + } + + return this.withPatches(getPatches() + .stream() + .filter(it -> it != patch) + .toList()).reconstructByPatches(); + } + + public GameInstanceManifest reconstructByPatches() { + if (inheritsFrom() != null || !isRoot()) { + throw new IllegalArgumentException("Cannot reconstruct a manifest that inherits from another or is not root"); + } + + if (patches() == null || patches().isEmpty()) { + return this; + } + + var builder = new GameInstanceManifest.Builder(); + for (GameInstancePatch patch : patches()) { + builder.merge(patch); + } + builder.setId(id()); + builder.setJar(jar() == null ? id() : jar()); + builder.setRoot(true); + builder.setPatches(patches()); + return builder.toManifest(); + } + + public static final class Builder { // @formatter:off private @Nullable GameInstanceID id; private @Nullable String minecraftArguments; @@ -720,10 +714,10 @@ private static final class Builder { private @Nullable JsonObject rawJson; // @formatter:on - Builder() { + public Builder() { } - Builder(GameInstanceManifest manifest) { + public Builder(GameInstanceManifest manifest) { this.id = manifest.id; this.minecraftArguments = manifest.minecraftArguments; this.arguments = manifest.arguments; @@ -907,7 +901,36 @@ public void setPatches(@Nullable List patches) { } } - GameInstanceManifest toManifest() { + public void merge(GameInstancePatch patch) { + if (patch.minecraftArguments() != null) + this.minecraftArguments = patch.minecraftArguments(); + this.arguments = Arguments.merge(this.arguments, patch.arguments()); + if (patch.mainClass() != null) + this.mainClass = patch.mainClass(); + if (patch.assetIndex() != null) + this.assetIndex = patch.assetIndex(); + if (patch.assets() != null) + this.assets = patch.assets(); + if (patch.complianceLevel() != null) + this.complianceLevel = patch.complianceLevel(); + if (patch.javaVersion() != null) + this.javaVersion = patch.javaVersion(); + this.libraries = Lang.merge(this.libraries, patch.libraries()); + this.compatibilityRules = Lang.merge(this.compatibilityRules, patch.compatibilityRules()); + if (patch.downloads() != null) + this.downloads = patch.downloads(); + if (patch.logging() != null) + this.logging = patch.logging(); + if (patch.type() != null) + this.type = patch.type(); + if (patch.time() != null) + this.time = patch.time(); + if (patch.releaseTime() != null) + this.releaseTime = patch.releaseTime(); + this.minimumLauncherVersion = Lang.merge(this.minimumLauncherVersion, patch.minimumLauncherVersion(), Math::max); + } + + public GameInstanceManifest toManifest() { if (id == null) { throw new IllegalStateException("id is null"); } 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 06fd1c6985b..cba5cab879c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java @@ -23,7 +23,6 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonPrimitive; import org.jackhuang.hmcl.util.ImmutableSequencedMap; -import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.InstantTypeAdapter; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.LowerCaseEnumTypeAdapter; @@ -867,29 +866,4 @@ public JsonObject toJsonObject() { return json; } - GameInstanceManifest merge(GameInstanceManifest parent) { - return new GameInstanceManifest( - parent.id(), - minecraftArguments == null ? parent.minecraftArguments() : minecraftArguments, - Arguments.merge(parent.arguments(), arguments), - mainClass == null ? parent.mainClass() : mainClass, - null, // inheritsFrom - parent.jar(), - assetIndex == null ? parent.assetIndex() : assetIndex, - assets == null ? parent.assets() : assets, - complianceLevel, - javaVersion == null ? parent.javaVersion() : javaVersion, - Lang.merge(this.libraries, parent.libraries()), - Lang.merge(parent.compatibilityRules(), this.compatibilityRules), - downloads == null ? parent.downloads() : downloads, - logging == null ? parent.logging() : logging, - type == null ? parent.type() : type, - time == null ? parent.time() : time, - releaseTime == null ? parent.releaseTime() : releaseTime, - Lang.merge(minimumLauncherVersion, parent.minimumLauncherVersion(), Math::max), - true, - hidden, - parent.patches(), - null); - } } 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 e1741129ff0..cb07a96aaa0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -21,7 +21,6 @@ import org.jetbrains.annotations.NotNullByDefault; import java.nio.file.Path; -import java.util.Optional; /// Provides indexed access to local game instances and the filesystem layout used by those instances. /// @@ -67,12 +66,6 @@ default Path getBaseDirectory() { /// @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 - GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException; - /// Returns whether the instance exists in the current repository index. /// /// @param instanceId the instance id @@ -126,18 +119,6 @@ default Path getInstanceRoot(GameInstanceID instanceId) { return getLayout().getInstanceRoot(instanceId); } - /// Returns the primary client jar path for a manifest. - /// - /// @param manifest the manifest whose jar should be located - /// @return the primary client jar path - Path getInstanceJar(GameInstanceManifest manifest); - - /// Detects the Minecraft game version associated with a manifest. - /// - /// @param manifest the manifest to inspect - /// @return the detected Minecraft game version, or empty if it cannot be determined - Optional getGameVersion(GameInstanceManifest manifest); - /// Renames an instance and updates repository-managed references. /// /// @param from the current instance id diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java index 05501087650..49d4036e458 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -17,12 +17,13 @@ */ 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.List; -import java.util.Optional; +import java.util.*; /// Launch-manifest library and argument adjustments used at resolve time and launch time. /// @@ -50,7 +51,7 @@ public static GameInstanceManifest repairForLaunch(GameInstanceManifest manifest } GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); - GameInstanceManifest repaired = manifest; + GameInstanceManifest repaired = uniqueLibraries(manifest); @Nullable String mainClass = repaired.mainClass(); if (GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(mainClass)) { @@ -87,7 +88,7 @@ private static GameInstanceManifest repairLaunchWrapper( @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()) { + if (analyzer.has(GameComponentType.LITELOADER) && !analyzer.hasForgeModLauncher()) { builder.replaceTweakClass( GameComponentAnalyzer.LITELOADER_TWEAKER, GameComponentAnalyzer.LITELOADER_TWEAKER, @@ -107,7 +108,7 @@ private static GameInstanceManifest repairLaunchWrapper( !reorderTweakClass, reorderTweakClass); } - } else if (analyzer.hasModLauncher()) { + } else if (analyzer.hasForgeModLauncher()) { // Prefer ModLauncher over LaunchWrapper when both are present. mainClass = GameComponentAnalyzer.MOD_LAUNCHER_MAIN; for (String optiFineTweaker : GameComponentAnalyzer.OPTIFINE_TWEAKERS) { @@ -128,7 +129,7 @@ private static GameInstanceManifest repairLaunchWrapper( } boolean hasForge = analyzer.has(GameComponentType.FORGE); - boolean hasModLauncher = analyzer.hasModLauncher(); + boolean hasModLauncher = analyzer.hasForgeModLauncher(); for (String forgeTweaker : GameComponentAnalyzer.FORGE_TWEAKERS) { if (!hasForge) { builder.removeTweakClass(forgeTweaker); @@ -244,4 +245,67 @@ private static GameInstanceManifest removeLegacyLog4jPatch(GameInstanceManifest } return manifest; } + + /// 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); + } + } 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 3fc315cccaf..d0798fa65be 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Library.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Library.java @@ -138,6 +138,14 @@ public Library(Artifact artifact, @Nullable String url, @Nullable LibrariesDownl this(artifact, url, downloads, null, null, null, null, null, null); } + public Library(String group, String name, String version) { + this(group, name, version, null); + } + + public Library(String group, String name, String version, @Nullable String classifier) { + this(new Artifact(group, name, version, classifier)); + } + public String groupId() { return artifact.getGroup(); } 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 dbaeb43056f..82f16243a28 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -148,7 +148,9 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { if (!options.isNoGeneratedJVMArgs()) { appendJvmArgs(res); - res.addDefault("-Dminecraft.client.jar=", FileUtils.getAbsolutePath(instance.getRepository().getInstanceJar(manifest))); + Path clientJar = instance.getInstanceJarFile(); + + res.addDefault("-Dminecraft.client.jar=", FileUtils.getAbsolutePath(clientJar)); if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { res.addDefault("-Xdock:name=", "Minecraft " + manifest.id()); @@ -278,11 +280,11 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { libraryClasspath.removeIf(c -> c.contains("2.9.4-nightly-20150209")); } - Path jar = instance.getRepository().getInstanceJar(manifest); + Path jar = instance.getInstanceJarFile(); if (!Files.isRegularFile(jar)) throw new IOException("Minecraft jar does not exist"); Set classpath = new LinkedHashSet<>(libraryClasspath); - classpath.add(FileUtils.getAbsolutePath(jar.toAbsolutePath())); + classpath.add(FileUtils.getAbsolutePath(jar)); // Provided Minecraft arguments Path gameAssets = instance.getActualAssetDirectory(manifest.getAssetIndex().getId()); @@ -633,7 +635,7 @@ protected Map getConfigurations() { pair("${resolution_height}", options.getHeight().toString()), pair("${library_directory}", FileUtils.getAbsolutePath(instance.getLayout().getLibrariesDirectory())), pair("${classpath_separator}", File.pathSeparator), - pair("${primary_jar}", FileUtils.getAbsolutePath(instance.getRepository().getInstanceJar(manifest))), + pair("${primary_jar}", FileUtils.getAbsolutePath(instance.getInstanceJarFile())), pair("${language}", Locale.getDefault().toLanguageTag()), // defined by HMCL @@ -643,7 +645,7 @@ protected Map getConfigurations() { 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(instance.getRepository().getInstanceJar(manifest))) + pair("${primary_jar_name}", FileUtils.getName(instance.getInstanceJarFile())) ); } 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 0e5beda3425..51a4f53ea6a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java @@ -31,7 +31,7 @@ /// 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()]. +/// [GameInstance#getResolvedManifest()]. public abstract class Launcher { /// The instance being launched. 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 6d649e4f482..0c68d91f48e 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 @@ -20,9 +20,7 @@ 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.GameComponentType; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.modpack.*; import org.jackhuang.hmcl.task.CacheFileTask; import org.jackhuang.hmcl.task.Task; @@ -31,6 +29,7 @@ import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.net.URI; @@ -40,11 +39,9 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/** - * Install a downloaded CurseForge modpack. - * - * @author huangyuhui - */ +/// Install a downloaded CurseForge modpack. +/// +/// @author huangyuhui public final class CurseInstallTask extends Task { private final DefaultDependencyManager dependencyManager; @@ -53,73 +50,127 @@ public final class CurseInstallTask extends Task { private final Modpack modpack; private final CurseManifest manifest; private final GameInstanceID instanceId; - private final String iconUrl; + + /// Existing instance selecting update mode, or `null` for a new installation. + private final @Nullable DefaultGameInstance updateTarget; + + /// Optional remote icon URL supplied by the install source. + private final @Nullable String iconUrl; private final Path run; - private final ModpackConfiguration config; - private String iconExt; - private Task downloadIconTask; + + /// Previous modpack configuration when updating, or `null` for a new installation. + private final @Nullable ModpackConfiguration config; + + /// Validated extension of the scheduled icon download, or `null` when no icon is scheduled. + private @Nullable String iconExt; + + /// Scheduled icon download corresponding to [#iconExt], or `null` when absent. + private @Nullable Task downloadIconTask; private final List> dependents = new ArrayList<>(4); private final List> dependencies = new ArrayList<>(1); - /** - * Constructor. - * - * @param dependencyManager the dependency manager. - * @param zipFile the CurseForge modpack file. - * @param manifest The manifest content of given CurseForge modpack. - * @param instanceId the new instance ID - */ - public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, Modpack modpack, CurseManifest manifest, GameInstanceID instanceId, String iconUrl) { + /// Creates a task that installs a new CurseForge modpack instance. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the CurseForge modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the CurseForge manifest + /// @param instanceId the id of the new instance + /// @param iconUrl the optional icon URL, or `null` + /// @throws IllegalStateException if the target cannot be reserved or another repository draft + /// is open + public CurseInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + CurseManifest manifest, + GameInstanceID instanceId, + @Nullable String iconUrl) { + this(dependencyManager, zipFile, modpack, manifest, instanceId, null, iconUrl); + } + + /// Creates a task that updates an existing CurseForge modpack instance. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the CurseForge modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the CurseForge manifest + /// @param instance the existing instance to update + /// @param iconUrl the optional icon URL, or `null` + /// @throws IllegalArgumentException if `instance` belongs to another repository, has no + /// modpack configuration, or records another provider type + /// @throws IllegalStateException if `instance` is not the exact currently published object + /// or another repository draft is open + public CurseInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + CurseManifest manifest, + DefaultGameInstance instance, + @Nullable String iconUrl) { + this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance, iconUrl); + } + + /// Creates a CurseForge installation task in the mode selected by `updateTarget`. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the CurseForge modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the CurseForge manifest + /// @param instanceId the target instance id + /// @param updateTarget the existing instance selecting update mode, or `null` for install + /// @param iconUrl the optional icon URL, or `null` + /// @throws IllegalArgumentException if an update target has no compatible configuration + /// @throws IllegalStateException if the target cannot be reserved, an update target is not + /// the exact published object, or another draft is open + private CurseInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + CurseManifest manifest, + GameInstanceID instanceId, + @Nullable DefaultGameInstance updateTarget, + @Nullable String iconUrl) { this.dependencyManager = dependencyManager; this.zipFile = zipFile; this.modpack = modpack; this.manifest = manifest; this.instanceId = instanceId; + this.updateTarget = updateTarget; this.iconUrl = iconUrl; this.repository = dependencyManager.getGameRepository(); this.run = repository.getLayout().getInstanceRoot(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().id(instanceId).component(GameComponentType.GAME, manifest.minecraft().gameVersion()); - for (CurseManifestModLoader modLoader : manifest.minecraft().modLoaders()) { - if (modLoader.id().startsWith("forge-")) { - builder.component(GameComponentType.FORGE, modLoader.id().substring("forge-".length())); - } else if (modLoader.id().startsWith("fabric-")) { - builder.component(GameComponentType.FABRIC, modLoader.id().substring("fabric-".length())); - } else if (modLoader.id().startsWith("neoforge-")) { - builder.component(GameComponentType.NEO_FORGE, modLoader.id().substring("neoforge-".length())); + if (this.updateTarget != null && Files.notExists(json)) + throw new IllegalArgumentException("Instance " + instanceId + " is not a Curse modpack. Cannot update this instance."); + + @Nullable ModpackConfiguration config = null; + try { + if (this.updateTarget != null && Files.exists(json)) { + config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(CurseManifest.class)); + + if (config == null || !CurseModpackProvider.INSTANCE.getName().equals(config.getType())) + throw new IllegalArgumentException("Instance " + instanceId + " is not a Curse modpack. Cannot update this instance."); } + } catch (JsonParseException | IOException ignore) { } - dependents.add(builder.buildAsync()); + this.config = config; onDone().register(event -> { - Exception ex = event.getTask().getException(); - if (event.isFailed()) { + @Nullable Exception ex = event.getTask().getException(); + if (this.updateTarget == null && event.isFailed()) { if (!(ex instanceof ModpackCompletionException)) { repository.removeInstanceFromDisk(instanceId); } } }); - ModpackConfiguration config = null; - try { - if (Files.exists(json)) { - config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(CurseManifest.class)); - - if (!CurseModpackProvider.INSTANCE.getName().equals(config.getType())) - throw new IllegalArgumentException("Instance " + instanceId + " is not a Curse modpack. Cannot update this instance."); - } - } catch (JsonParseException | IOException ignore) { - } - 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.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); - URI iconUri = NetworkUtils.toURIOrNull(iconUrl); + @Nullable URI iconUri = NetworkUtils.toURIOrNull(iconUrl); if (iconUri != null) { String ext = FileUtils.getExtension(StringUtils.substringAfter(iconUri.getPath(), '/')).toLowerCase(Locale.ROOT); if (Modpack.SUPPORTED_ICON_EXTS.contains(ext)) { @@ -127,6 +178,23 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile dependents.add(downloadIconTask = new CacheFileTask(dependencyManager.getDownloadProvider().injectURLWithCandidates(iconUrl))); } } + + try (GameBuilder builder = this.updateTarget == null + ? dependencyManager.newGameBuilder(instanceId) + : dependencyManager.newGameBuilder(this.updateTarget)) { + builder.enableIsolation() + .component(GameComponentType.GAME, manifest.minecraft().gameVersion()); + for (CurseManifestModLoader modLoader : manifest.minecraft().modLoaders()) { + if (modLoader.id().startsWith("forge-")) { + builder.component(GameComponentType.FORGE, modLoader.id().substring("forge-".length())); + } else if (modLoader.id().startsWith("fabric-")) { + builder.component(GameComponentType.FABRIC, modLoader.id().substring("fabric-".length())); + } else if (modLoader.id().startsWith("neoforge-")) { + builder.component(GameComponentType.NEO_FORGE, modLoader.id().substring("neoforge-".length())); + } + } + dependents.add(0, builder.buildAsync()); + } } @Override @@ -174,10 +242,10 @@ public void execute() throws Exception { // resolves those file names and writes the enriched manifest to // manifest.json, so read from there when available. Path oldManifestFile = repository.getLayout().getInstanceRoot(instanceId).resolve("manifest.json"); - List oldFiles = config.getManifest().files(); + @Nullable List oldFiles = config.getManifest().files(); if (Files.exists(oldManifestFile)) { try { - CurseManifest oldManifest = JsonUtils.fromJsonFile(oldManifestFile, CurseManifest.class); + @Nullable CurseManifest oldManifest = JsonUtils.fromJsonFile(oldManifestFile, CurseManifest.class); if (oldManifest != null) { oldFiles = oldManifest.files(); } @@ -201,9 +269,13 @@ public void execute() throws Exception { Files.createDirectories(root); JsonUtils.writeToJsonFile(root.resolve("manifest.json"), manifest); - if (iconExt != null && Modpack.SUPPORTED_ICON_NAMES.stream().map(root::resolve).allMatch(Files::notExists)) { + @Nullable String iconExtension = iconExt; + @Nullable Task iconTask = downloadIconTask; + if (iconExtension != null + && iconTask != null + && Modpack.SUPPORTED_ICON_NAMES.stream().map(root::resolve).allMatch(Files::notExists)) { try { - Files.copy(downloadIconTask.getResult(), root.resolve("icon." + iconExt)); + Files.copy(iconTask.getResult(), root.resolve("icon." + iconExtension)); } catch (Exception e) { LOG.warning("Failed to copy modpack icon", e); } 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 9cc2fc59aa3..32dcd04a729 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 @@ -54,7 +54,7 @@ public Task createUpdateTask(DefaultDependencyManager dependencyManager, Defa if (!(modpack.getManifest() instanceof CurseManifest curseManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(instance, new CurseInstallTask(dependencyManager, zipFile, modpack, curseManifest, instance.getId(), null)); + return new ModpackUpdateTask(instance, new CurseInstallTask(dependencyManager, zipFile, modpack, curseManifest, instance, null)); } @Override 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 c2a801815c9..3af6a05c462 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 @@ -37,6 +37,7 @@ import java.util.List; import java.util.Optional; +/// Installs or updates a local MCBBS modpack using the mode selected at construction. public final class McbbsModpackLocalInstallTask extends Task { private final DefaultDependencyManager dependencyManager; @@ -44,53 +45,113 @@ public final class McbbsModpackLocalInstallTask extends Task { private final Modpack modpack; private final McbbsModpackManifest manifest; private final GameInstanceID instanceId; - private final boolean update; + /// Existing instance selecting update mode, or `null` for a new installation. + private final @Nullable DefaultGameInstance updateTarget; private final DefaultGameRepository repository; private final MinecraftInstanceTask instanceTask; private final List> dependencies = new ArrayList<>(2); private final List> dependents = new ArrayList<>(4); - public McbbsModpackLocalInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, Modpack modpack, McbbsModpackManifest manifest, GameInstanceID instanceId) { + /// Creates a task that installs a new MCBBS modpack instance. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the MCBBS modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the MCBBS manifest + /// @param instanceId the id of the new instance + /// @throws IllegalStateException if the target cannot be reserved or another repository draft + /// is open + public McbbsModpackLocalInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + McbbsModpackManifest manifest, + GameInstanceID instanceId) { + this(dependencyManager, zipFile, modpack, manifest, instanceId, null); + } + + /// Creates a task that updates an existing MCBBS modpack instance. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the MCBBS modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the MCBBS manifest + /// @param instance the existing instance to update + /// @throws IllegalArgumentException if `instance` belongs to another repository, has no + /// modpack configuration, or records another provider type + /// @throws IllegalStateException if `instance` is not the exact currently published object + /// or another repository draft is open + public McbbsModpackLocalInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + McbbsModpackManifest manifest, + DefaultGameInstance instance) { + this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance); + } + + /// Creates an MCBBS installation task in the mode selected by `updateTarget`. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the MCBBS modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the MCBBS manifest + /// @param instanceId the target instance id + /// @param updateTarget the existing instance selecting update mode, or `null` for install + /// @throws IllegalArgumentException if an update target has no compatible configuration + /// @throws IllegalStateException if the target cannot be reserved, an update target is not + /// the exact published object, or another draft is open + private McbbsModpackLocalInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + McbbsModpackManifest manifest, + GameInstanceID instanceId, + @Nullable DefaultGameInstance updateTarget) { this.dependencyManager = dependencyManager; this.zipFile = zipFile; this.modpack = modpack; this.manifest = manifest; this.instanceId = instanceId; + this.updateTarget = updateTarget; this.repository = dependencyManager.getGameRepository(); Path run = repository.getLayout().getInstanceRoot(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); - + if (this.updateTarget != null && Files.notExists(json)) + throw new IllegalArgumentException("Instance " + instanceId + " is not a Mcbbs modpack. Cannot update this instance."); - GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); - for (McbbsModpackManifest.Addon addon : manifest.getAddons()) { - @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); - if (componentType != null) - builder.component(componentType, addon.getVersion()); - } - - dependents.add(builder.buildAsync()); - onDone().register(event -> { - if (event.isFailed()) - repository.removeInstanceFromDisk(instanceId); - }); - - ModpackConfiguration config = null; + @Nullable ModpackConfiguration config = null; try { - if (Files.exists(json)) { + if (this.updateTarget != null && Files.exists(json)) { config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(McbbsModpackManifest.class)); - if (!McbbsModpackProvider.INSTANCE.getName().equals(config.getType())) + if (config == null || !McbbsModpackProvider.INSTANCE.getName().equals(config.getType())) throw new IllegalArgumentException("Instance " + instanceId + " is not a Mcbbs modpack. Cannot update this instance."); } } catch (JsonParseException | IOException ignore) { } + + onDone().register(event -> { + if (this.updateTarget == null && event.isFailed()) + repository.removeInstanceFromDisk(instanceId); + }); + 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.getLayout().getModpackConfigurationFile(instanceId)); dependents.add(instanceTask.withStage("hmcl.modpack")); + + try (GameBuilder builder = this.updateTarget == null + ? dependencyManager.newGameBuilder(instanceId) + : dependencyManager.newGameBuilder(this.updateTarget)) { + builder.enableIsolation(); + for (McbbsModpackManifest.Addon addon : manifest.getAddons()) { + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); + } + dependents.add(0, builder.buildAsync()); + } } @Override @@ -107,7 +168,7 @@ public List> getDependencies() { public void execute() throws Exception { GameInstanceManifest instanceManifest = repository.getInstanceManifest(instanceId); Optional mcbbsPatch = instanceManifest.getPatches().stream().filter(patch -> PATCH_NAME.equals(patch.id())).findFirst(); - if (!update) { + if (this.updateTarget == null) { GameInstancePatch patch = new GameInstancePatch(PATCH_NAME).withLibraries(manifest.getLibraries()); dependencies.add(repository.saveAsync(instanceManifest.addPatch(patch))); } else if (mcbbsPatch.isPresent()) { 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 d4993662b46..e19eccbdcff 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 @@ -50,7 +50,7 @@ public Task createUpdateTask(DefaultDependencyManager dependencyManager, Defa if (!(modpack.getManifest() instanceof McbbsModpackManifest mcbbsModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(instance, new McbbsModpackLocalInstallTask(dependencyManager, zipFile, modpack, mcbbsModpackManifest, instance.getId())); + return new ModpackUpdateTask(instance, new McbbsModpackLocalInstallTask(dependencyManager, zipFile, modpack, mcbbsModpackManifest, instance)); } @Override 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 ab579ff1b9f..dbcb979fee8 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 @@ -20,9 +20,7 @@ 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.GameComponentType; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.modpack.*; import org.jackhuang.hmcl.task.CacheFileTask; import org.jackhuang.hmcl.task.Task; @@ -30,6 +28,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.net.URI; @@ -47,80 +46,129 @@ public class ModrinthInstallTask extends Task { private final Modpack modpack; private final ModrinthManifest manifest; private final GameInstanceID instanceId; - private final String iconUrl; + + /// Existing instance selecting update mode, or `null` for a new installation. + private final @Nullable DefaultGameInstance updateTarget; + + /// Optional remote icon URL supplied by the install source. + private final @Nullable String iconUrl; private final Path run; - private final ModpackConfiguration config; - private String iconExt; - private Task downloadIconTask; + + /// Previous modpack configuration when updating, or `null` for a new installation. + private final @Nullable ModpackConfiguration config; + + /// Validated extension of the scheduled icon download, or `null` when no icon is scheduled. + private @Nullable String iconExt; + + /// Scheduled icon download corresponding to [#iconExt], or `null` when absent. + private @Nullable Task downloadIconTask; private final List> dependents = new ArrayList<>(4); private final List> dependencies = new ArrayList<>(1); - public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, Modpack modpack, ModrinthManifest manifest, GameInstanceID instanceId, String iconUrl) { + /// Creates a task that installs a new Modrinth modpack instance. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the Modrinth modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the Modrinth index + /// @param instanceId the id of the new instance + /// @param iconUrl the optional icon URL, or `null` + /// @throws IllegalStateException if the manifest declares an unsupported mod loader, the target + /// cannot be reserved, or another repository draft is open + public ModrinthInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + ModrinthManifest manifest, + GameInstanceID instanceId, + @Nullable String iconUrl) { + this(dependencyManager, zipFile, modpack, manifest, instanceId, null, iconUrl); + } + + /// Creates a task that updates an existing Modrinth modpack instance. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the Modrinth modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the Modrinth index + /// @param instance the existing instance to update + /// @param iconUrl the optional icon URL, or `null` + /// @throws IllegalArgumentException if `instance` belongs to another repository, has no + /// modpack configuration, or records another provider type + /// @throws IllegalStateException if the manifest declares an unsupported mod loader, + /// `instance` is not the exact currently published object, or + /// another repository draft is open + public ModrinthInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + ModrinthManifest manifest, + DefaultGameInstance instance, + @Nullable String iconUrl) { + this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance, iconUrl); + } + + /// Creates a Modrinth installation task in the mode selected by `updateTarget`. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the Modrinth modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the Modrinth index + /// @param instanceId the target instance id + /// @param updateTarget the existing instance selecting update mode, or `null` for install + /// @param iconUrl the optional icon URL, or `null` + /// @throws IllegalArgumentException if an update target has no compatible configuration + /// @throws IllegalStateException if the manifest declares an unsupported mod loader, the + /// target cannot be reserved, an update target is not the exact + /// published object, or another draft is open + private ModrinthInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + ModrinthManifest manifest, + GameInstanceID instanceId, + @Nullable DefaultGameInstance updateTarget, + @Nullable String iconUrl) { this.dependencyManager = dependencyManager; this.zipFile = zipFile; this.modpack = modpack; this.manifest = manifest; this.instanceId = instanceId; + this.updateTarget = updateTarget; this.iconUrl = iconUrl; this.repository = dependencyManager.getGameRepository(); this.run = repository.getLayout().getInstanceRoot(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().id(instanceId); - builder.component(GameComponentType.GAME, manifest.getGameVersion()); - for (Map.Entry modLoader : manifest.getDependencies().entrySet()) { - switch (modLoader.getKey()) { - case "minecraft": - break; - case "forge": - builder.component(GameComponentType.FORGE, modLoader.getValue()); - break; - case "neoforge": - // https://github.com/HMCL-dev/HMCL/pull/5170 - case "neo-forge": - builder.component(GameComponentType.NEO_FORGE, modLoader.getValue()); - break; - case "fabric-loader": - builder.component(GameComponentType.FABRIC, modLoader.getValue()); - break; - case "quilt-loader": - builder.component(GameComponentType.QUILT, modLoader.getValue()); - break; - default: - throw new IllegalStateException("Unsupported mod loader " + modLoader.getKey()); + if (this.updateTarget != null && Files.notExists(json)) + throw new IllegalArgumentException("Instance " + instanceId + " is not a Modrinth modpack. Cannot update this instance."); + + @Nullable ModpackConfiguration config = null; + try { + if (this.updateTarget != null && Files.exists(json)) { + config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(ModrinthManifest.class)); + + if (config == null || !ModrinthModpackProvider.INSTANCE.getName().equals(config.getType())) + throw new IllegalArgumentException("Instance " + instanceId + " is not a Modrinth modpack. Cannot update this instance."); } + } catch (JsonParseException | IOException ignore) { } - dependents.add(builder.buildAsync()); + this.config = config; onDone().register(event -> { - Exception ex = event.getTask().getException(); - if (event.isFailed()) { + @Nullable Exception ex = event.getTask().getException(); + if (this.updateTarget == null && event.isFailed()) { if (!(ex instanceof ModpackCompletionException)) { repository.removeInstanceFromDisk(instanceId); } } }); - ModpackConfiguration config = null; - try { - if (Files.exists(json)) { - config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(ModrinthManifest.class)); - - if (!ModrinthModpackProvider.INSTANCE.getName().equals(config.getType())) - throw new IllegalArgumentException("Instance " + instanceId + " is not a Modrinth modpack. Cannot update this instance."); - } - } catch (JsonParseException | IOException ignore) { - } - - 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.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); - URI iconUri = NetworkUtils.toURIOrNull(iconUrl); + @Nullable URI iconUri = NetworkUtils.toURIOrNull(iconUrl); if (iconUri != null) { String ext = FileUtils.getExtension(StringUtils.substringAfter(iconUri.getPath(), '/')).toLowerCase(Locale.ROOT); if (Modpack.SUPPORTED_ICON_EXTS.contains(ext)) { @@ -129,6 +177,36 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF dependents.add(downloadIconTask = new CacheFileTask(dependencyManager.getDownloadProvider().injectURLWithCandidates(iconUrl))); } } + + try (GameBuilder builder = this.updateTarget == null + ? dependencyManager.newGameBuilder(instanceId) + : dependencyManager.newGameBuilder(this.updateTarget)) { + builder.enableIsolation(); + builder.component(GameComponentType.GAME, manifest.getGameVersion()); + for (Map.Entry modLoader : manifest.getDependencies().entrySet()) { + switch (modLoader.getKey()) { + case "minecraft": + break; + case "forge": + builder.component(GameComponentType.FORGE, modLoader.getValue()); + break; + case "neoforge": + // https://github.com/HMCL-dev/HMCL/pull/5170 + case "neo-forge": + builder.component(GameComponentType.NEO_FORGE, modLoader.getValue()); + break; + case "fabric-loader": + builder.component(GameComponentType.FABRIC, modLoader.getValue()); + break; + case "quilt-loader": + builder.component(GameComponentType.QUILT, modLoader.getValue()); + break; + default: + throw new IllegalStateException("Unsupported mod loader " + modLoader.getKey()); + } + } + dependents.add(0, builder.buildAsync()); + } } @Override @@ -158,9 +236,13 @@ public void execute() throws Exception { Files.createDirectories(root); JsonUtils.writeToJsonFile(root.resolve("modrinth.index.json"), manifest); - if (iconExt != null && Modpack.SUPPORTED_ICON_NAMES.stream().map(root::resolve).allMatch(Files::notExists)) { + @Nullable String iconExtension = iconExt; + @Nullable Task iconTask = downloadIconTask; + if (iconExtension != null + && iconTask != null + && Modpack.SUPPORTED_ICON_NAMES.stream().map(root::resolve).allMatch(Files::notExists)) { try { - Files.copy(downloadIconTask.getResult(), root.resolve("icon." + iconExt)); + Files.copy(iconTask.getResult(), root.resolve("icon." + iconExtension)); } catch (Exception e) { LOG.warning("Failed to copy modpack icon", e); } 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 f1eb340bbae..219402aef3a 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 @@ -52,7 +52,7 @@ public Task createUpdateTask(DefaultDependencyManager dependencyManager, Defa if (!(modpack.getManifest() instanceof ModrinthManifest modrinthManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(instance, new ModrinthInstallTask(dependencyManager, zipFile, modpack, modrinthManifest, instance.getId(), null)); + return new ModpackUpdateTask(instance, new ModrinthInstallTask(dependencyManager, zipFile, modpack, modrinthManifest, instance, null)); } @Override 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 780c9be2840..0d1085fdf6d 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 @@ -40,51 +40,128 @@ import java.nio.file.*; import java.util.*; -/** - *

A task transforming MultiMC Modpack Scheme to Official Launcher Scheme. - * The transforming process contains 7 stage: - *

    - *
  • General Setup: Compute checksum and copy 'overrides' files.
  • - *
  • Load Components: Parse all local Json-Patch and prepare to fetch others from Internet.
  • - *
  • Resolve Json-Patch: Fetch remote Json-Patch and their dependencies.
  • - *
  • Build Artifact: Transform Json-Patch to Official Scheme lossily, without original structure.
  • - *
  • Copy Embedded Files: Copy embedded libraries and icon.
  • - *
  • Assemble Game: Prepare to download main jar, libraries and assets.
  • - *
  • Download Game: Download files.
  • - *
  • Apply JAR mods: Apply JAR mods into main jar.
  • - *
- * See codes below for detailed implementation. - * - * @implNote To guarantee all features of MultiMC Modpack Scheme is super hard. - * As f*** MMC never provides a detailed API docs, most codes below is guessed from its source code. - * FUNCTIONS OF GAMES MIGHT NOT BE COMPLETELY THE SAME WITH MMC. - *

- */ +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// A task transforming MultiMC Modpack Scheme to Official Launcher Scheme. +/// The transforming process contains 7 stage: +/// +/// - General Setup: Compute checksum and copy 'overrides' files. +/// - Load Components: Parse all local Json-Patch and prepare to fetch others from Internet. +/// - Resolve Json-Patch: Fetch remote Json-Patch and their dependencies. +/// - Build Artifact: Transform Json-Patch to Official Scheme lossily, without original structure. +/// - Copy Embedded Files: Copy embedded libraries and icon. +/// - Assemble Game: Prepare to download main jar, libraries and assets. +/// - Download Game: Download files. +/// - Apply JAR mods: Apply JAR mods into main jar. +/// +/// See codes below for detailed implementation. +/// +/// @implNote To guarantee all features of MultiMC Modpack Scheme is super hard. +/// As f\*\*\* MMC never provides a detailed API docs, most codes below is guessed from its source code. +/// **FUNCTIONS OF GAMES MIGHT NOT BE COMPLETELY THE SAME WITH MMC.** public final class MultiMCModpackInstallTask extends Task { private final Path zipFile; private final Modpack modpack; private final MultiMCInstanceConfiguration manifest; private final GameInstanceID instanceId; + + /// Existing instance selecting update mode, or `null` for a new installation. + private final @Nullable DefaultGameInstance updateTarget; + private final DefaultGameRepository repository; private final List> dependents = new ArrayList<>(); private final List> dependencies = new ArrayList<>(); private final DefaultDependencyManager dependencyManager; - public MultiMCModpackInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, Modpack modpack, MultiMCInstanceConfiguration manifest, GameInstanceID instanceId) { + /// Previous modpack configuration when updating, or `null` for a new installation. + private final @Nullable ModpackConfiguration config; + + /// The repository transaction that owns a newly created instance root until publication. + private @Nullable DefaultGameRepositoryDraft draft; + + /// Whether this task successfully reserved a previously absent instance in its draft. + private boolean newInstallationReserved; + + /// Creates a MultiMC modpack installation task. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the source modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the MultiMC instance configuration + /// @param instanceId the id of the new instance + public MultiMCModpackInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + MultiMCInstanceConfiguration manifest, + GameInstanceID instanceId) { + this(dependencyManager, zipFile, modpack, manifest, instanceId, null); + } + + /// Creates a MultiMC modpack update task. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the source modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the MultiMC instance configuration + /// @param instance the existing instance to update + /// @throws IllegalArgumentException if `instance` belongs to another repository, has no + /// modpack configuration, or records another provider type + public MultiMCModpackInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + MultiMCInstanceConfiguration manifest, + DefaultGameInstance instance) { + this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance); + } + + /// Creates a MultiMC modpack task in the mode selected by `updateTarget`. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the source modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the MultiMC instance configuration + /// @param instanceId the target instance id + /// @param updateTarget the existing instance selecting update mode, or `null` for install + private MultiMCModpackInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + MultiMCInstanceConfiguration manifest, + GameInstanceID instanceId, + @Nullable DefaultGameInstance updateTarget) { this.zipFile = zipFile; this.modpack = modpack; this.manifest = manifest; this.instanceId = instanceId; + this.updateTarget = updateTarget; this.dependencyManager = dependencyManager; this.repository = dependencyManager.getGameRepository(); + if (this.updateTarget != null) { + dependencyManager.validateGameInstance(this.updateTarget); + } Path json = repository.getLayout().getModpackConfigurationFile(instanceId); - if (repository.hasInstance(instanceId) && Files.notExists(json)) - throw new IllegalArgumentException("Instance " + instanceId + " already exists."); + if (this.updateTarget != null && Files.notExists(json)) + throw new IllegalArgumentException("Instance " + instanceId + " is not a MultiMC modpack. Cannot update this instance."); + + @Nullable ModpackConfiguration config = null; + try { + if (this.updateTarget != null && Files.exists(json)) { + config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(MultiMCInstanceConfiguration.class)); + + if (config == null || !MultiMCModpackProvider.INSTANCE.getName().equals(config.getType())) + throw new IllegalArgumentException("Instance " + instanceId + " is not a MultiMC modpack. Cannot update this instance."); + } + } catch (JsonParseException | IOException ignore) { + } + this.config = config; onDone().register(event -> { - if (event.isFailed()) + abortOpenDraft(); + if (event.isFailed() && newInstallationReserved) repository.removeInstanceFromDisk(instanceId); }); } @@ -94,23 +171,36 @@ public boolean doPreExecute() { return true; } + /// Reserves the instance in a repository draft before preparing tasks that write its root. @Override public void preExecute() throws Exception { - // Stage #0: General Setup - { - Path run = repository.getLayout().getInstanceRoot(instanceId); - Path json = repository.getLayout().getModpackConfigurationFile(instanceId); + DefaultGameRepositoryDraft openedDraft = repository.openDraft(); + draft = openedDraft; + try { + // Construction fixes the mode; the captured snapshot only verifies that it is still valid. + boolean targetExists = openedDraft.getBaseSnapshot().hasInstance(instanceId); + if (this.updateTarget == null && targetExists) { + throw new IllegalStateException("Game instance already exists: " + instanceId); + } + if (this.updateTarget != null && !targetExists) { + throw new IllegalStateException("Game instance no longer exists: " + instanceId); + } - ModpackConfiguration config = null; + openedDraft.put(new GameInstanceManifest(instanceId)); + newInstallationReserved = this.updateTarget == null; + } catch (IOException | RuntimeException e) { try { - if (Files.exists(json)) { - config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(MultiMCInstanceConfiguration.class)); - - if (!MultiMCModpackProvider.INSTANCE.getName().equals(config.getType())) - throw new IllegalArgumentException("Instance " + instanceId + " is not a MultiMC modpack. Cannot update this instance."); - } - } catch (JsonParseException | IOException ignore) { + openedDraft.abort(); + } catch (IOException cleanupFailure) { + e.addSuppressed(cleanupFailure); } + draft = null; + throw e; + } + + // Stage #0: General Setup + { + Path run = repository.getLayout().getInstanceRoot(instanceId); String mcDirectory; try (FileSystem fs = openModpack()) { @@ -276,8 +366,8 @@ public void execute() throws Exception { // Stage #5: Assemble game files. { GameInstanceManifest instanceManifest = artifact.getManifest(); + requireDraft().put(instanceManifest); - dependencies.add(repository.saveAsync(artifact.getManifest())); dependencies.add(new GameAssetDownloadTask(dependencyManager, instanceManifest, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true)); dependencies.add(new GameLibrariesTask( dependencyManager, @@ -286,7 +376,7 @@ public void execute() throws Exception { true )); - Path instanceJar = repository.getInstanceJar(instanceManifest); + Path instanceJar = getPrimaryJarFile(); dependencies.add(new GameDownloadTask(dependencyManager, instanceManifest) .thenAcceptAsync(cachedJar -> FileUtils.copyFile(cachedJar, instanceJar))); } @@ -305,29 +395,74 @@ public boolean doPostExecute() { return true; } + /// Applies JAR mods after downloads succeed and then publishes the completed manifest. @Override public void postExecute() throws Exception { MultiMCInstancePatch.ResolvedInstance artifact = Objects.requireNonNull(getResult(), "ResolvedInstance"); List files = artifact.getJarModFileNames(); - if (!isDependenciesSucceeded() || files.isEmpty()) { + if (!isDependenciesSucceeded()) { return; } - // Stage #7: Apply jar mods. - try (FileSystem fs = openModpack()) { - Path root = getRootPath(fs).resolve("jarmods"); - - try (FileSystem mc = CompressingUtils.writable( - 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()) { - FileUtils.copyDirectory(jm.getPath("/"), mc.getPath("/")); + if (!files.isEmpty()) { + // Stage #7: Apply jar mods. + try (FileSystem fs = openModpack()) { + Path root = getRootPath(fs).resolve("jarmods"); + + try (FileSystem mc = CompressingUtils.writable( + getPrimaryJarFile() + ).setAutoDetectEncoding(true).build()) { + for (String fileName : files) { + try (FileSystem jm = CompressingUtils.readonly(root.resolve(fileName)).setAutoDetectEncoding(true).build()) { + FileUtils.copyDirectory(jm.getPath("/"), mc.getPath("/")); + } } } } } + + requireDraft().commit(); + } + + /// Returns the primary JAR path retained by the current draft. + /// + /// Updates use the existing manifest's sibling JAR, while new installations use the layout's + /// conventional path. + /// + /// @return the primary JAR destination + private Path getPrimaryJarFile() { + if (updateTarget == null) { + return repository.getLayout().getInstanceJarFile(instanceId); + } + + Path manifestFile = updateTarget.getManifestFile(); + return manifestFile.resolveSibling(FileUtils.getNameWithoutExtension(manifestFile) + ".jar"); + } + + /// Returns the open draft associated with this task. + /// + /// @return the open draft + /// @throws IllegalStateException if the task has not reserved a draft or already released it + private DefaultGameRepositoryDraft requireDraft() { + @Nullable DefaultGameRepositoryDraft currentDraft = draft; + if (currentDraft == null || !currentDraft.isOpen()) { + throw new IllegalStateException("MultiMC installation draft is not open"); + } + return currentDraft; + } + + /// Aborts the task's draft when execution ends before commit. + private void abortOpenDraft() { + @Nullable DefaultGameRepositoryDraft currentDraft = draft; + if (currentDraft == null || !currentDraft.isOpen()) { + return; + } + try { + currentDraft.abort(); + } catch (IOException e) { + LOG.warning("Failed to abort MultiMC installation draft for " + instanceId, e); + } } private FileSystem openModpack() throws IOException { 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 966dd0ef372..9243dc7f33c 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 @@ -53,7 +53,7 @@ public Task createUpdateTask(DefaultDependencyManager dependencyManager, Defa if (!(modpack.getManifest() instanceof MultiMCInstanceConfiguration multiMCInstanceConfiguration)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(instance, new MultiMCModpackInstallTask(dependencyManager, zipFile, modpack, multiMCInstanceConfiguration, instance.getId())); + return new ModpackUpdateTask(instance, new MultiMCModpackInstallTask(dependencyManager, zipFile, modpack, multiMCInstanceConfiguration, instance)); } 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 84a50b826fe..7604663a734 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 @@ -142,14 +142,15 @@ 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().id(instance.getId()); - for (ServerModpackManifest.Addon addon : remoteManifest.getAddons()) { - @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); - if (componentType != null) - builder.component(componentType, addon.getVersion()); - } + try (GameBuilder builder = dependencyManager.newGameBuilder(instance)) { + for (ServerModpackManifest.Addon addon : remoteManifest.getAddons()) { + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); + } - dependencies.add(builder.buildAsync()); + dependencies.add(builder.buildAsync()); + } } Path rootPath = instance.getInstanceRoot().toAbsolutePath().normalize(); 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 57c3361ebdd..44ee61997a4 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 @@ -20,9 +20,7 @@ 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.GameComponentType; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.modpack.MinecraftInstanceTask; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; @@ -38,53 +36,119 @@ import java.util.Collections; import java.util.List; +/// Installs or updates a local server modpack using the mode selected at construction. public class ServerModpackLocalInstallTask extends Task { private final Path zipFile; private final Modpack modpack; private final ServerModpackManifest manifest; private final GameInstanceID instanceId; + + /// Existing instance selecting update mode, or `null` for a new installation. + private final @Nullable DefaultGameInstance updateTarget; + private final DefaultGameRepository repository; private final List> dependencies = new ArrayList<>(); private final List> dependents = new ArrayList<>(4); - public ServerModpackLocalInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, Modpack modpack, ServerModpackManifest manifest, GameInstanceID instanceId) { + /// Creates a task that installs a new local server modpack instance. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the server modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the server modpack manifest + /// @param instanceId the id of the new instance + /// @throws IllegalStateException if the target cannot be reserved or another repository draft + /// is open + public ServerModpackLocalInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + ServerModpackManifest manifest, + GameInstanceID instanceId) { + this(dependencyManager, zipFile, modpack, manifest, instanceId, null); + } + + /// Creates a task that updates an existing local server modpack instance. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the server modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the server modpack manifest + /// @param instance the existing instance to update + /// @throws IllegalArgumentException if `instance` belongs to another repository, has no + /// modpack configuration, or records another provider type + /// @throws IllegalStateException if `instance` is not the exact currently published object + /// or another repository draft is open + public ServerModpackLocalInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + ServerModpackManifest manifest, + DefaultGameInstance instance) { + this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance); + } + + /// Creates a local server modpack task in the mode selected by `updateTarget`. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param zipFile the server modpack archive + /// @param modpack the parsed modpack metadata + /// @param manifest the server modpack manifest + /// @param instanceId the target instance id + /// @param updateTarget the existing instance selecting update mode, or `null` for install + /// @throws IllegalArgumentException if an update target has no compatible configuration + /// @throws IllegalStateException if the target cannot be reserved, an update target is not + /// the exact published object, or another draft is open + private ServerModpackLocalInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + Modpack modpack, + ServerModpackManifest manifest, + GameInstanceID instanceId, + @Nullable DefaultGameInstance updateTarget) { this.zipFile = zipFile; this.modpack = modpack; this.manifest = manifest; this.instanceId = instanceId; + this.updateTarget = updateTarget; this.repository = dependencyManager.getGameRepository(); Path run = repository.getLayout().getInstanceRoot(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().id(instanceId); - for (ServerModpackManifest.Addon addon : manifest.getAddons()) { - @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); - if (componentType != null) - builder.component(componentType, addon.getVersion()); - } + if (this.updateTarget != null && Files.notExists(json)) + throw new IllegalArgumentException("Instance " + instanceId + " is not a Server modpack. Cannot update this instance."); - dependents.add(builder.buildAsync()); - onDone().register(event -> { - if (event.isFailed()) - repository.removeInstanceFromDisk(instanceId); - }); - - ModpackConfiguration config = null; + @Nullable ModpackConfiguration config = null; try { - if (Files.exists(json)) { + if (this.updateTarget != null && Files.exists(json)) { config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(ServerModpackManifest.class)); - if (!ServerModpackProvider.INSTANCE.getName().equals(config.getType())) + if (config == null || !ServerModpackProvider.INSTANCE.getName().equals(config.getType())) throw new IllegalArgumentException("Instance " + instanceId + " is not a Server modpack. Cannot update this instance."); } } catch (JsonParseException | IOException ignore) { } + + onDone().register(event -> { + if (this.updateTarget == null && event.isFailed()) + repository.removeInstanceFromDisk(instanceId); + }); + 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.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); + + try (GameBuilder builder = this.updateTarget == null + ? dependencyManager.newGameBuilder(instanceId) + : dependencyManager.newGameBuilder(this.updateTarget)) { + builder.enableIsolation(); + for (ServerModpackManifest.Addon addon : manifest.getAddons()) { + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); + } + dependents.add(0, builder.buildAsync()); + } } @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 90e82f03924..d081bcc889a 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 @@ -51,7 +51,7 @@ public Task createUpdateTask(DefaultDependencyManager dependencyManager, Defa if (!(modpack.getManifest() instanceof ServerModpackManifest serverModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(instance, new ServerModpackLocalInstallTask(dependencyManager, zipFile, modpack, serverModpackManifest, instance.getId())); + return new ModpackUpdateTask(instance, new ServerModpackLocalInstallTask(dependencyManager, zipFile, modpack, serverModpackManifest, instance)); } @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 1f8394dedd6..50d9a16d448 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 @@ -20,9 +20,7 @@ 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.GameComponentType; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -35,47 +33,99 @@ import java.util.Collections; import java.util.List; +/// Installs or updates a remote server modpack using the mode selected at construction. public class ServerModpackRemoteInstallTask extends Task { private final GameInstanceID instanceId; + + /// Existing instance selecting update mode, or `null` for a new installation. + private final @Nullable DefaultGameInstance updateTarget; + private final DefaultDependencyManager dependency; private final DefaultGameRepository repository; private final List> dependencies = new ArrayList<>(1); private final List> dependents = new ArrayList<>(1); private final ServerModpackManifest manifest; - public ServerModpackRemoteInstallTask(DefaultDependencyManager dependencyManager, ServerModpackManifest manifest, GameInstanceID instanceId) { + /// Creates a task that installs a new remote server modpack instance. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param manifest the remote server modpack manifest + /// @param instanceId the id of the new instance + /// @throws IllegalStateException if the target cannot be reserved or another repository draft + /// is open + public ServerModpackRemoteInstallTask( + DefaultDependencyManager dependencyManager, + ServerModpackManifest manifest, + GameInstanceID instanceId) { + this(dependencyManager, manifest, instanceId, null); + } + + /// Creates a task that updates an existing remote server modpack instance. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param manifest the remote server modpack manifest + /// @param instance the existing instance to update + /// @throws IllegalArgumentException if `instance` belongs to another repository, has no + /// modpack configuration, or records another provider type + /// @throws IllegalStateException if `instance` is not the exact currently published object + /// or another repository draft is open + public ServerModpackRemoteInstallTask( + DefaultDependencyManager dependencyManager, + ServerModpackManifest manifest, + DefaultGameInstance instance) { + this(dependencyManager, manifest, instance.getId(), instance); + } + + /// Creates a remote server modpack task in the mode selected by `updateTarget`. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param manifest the remote server modpack manifest + /// @param instanceId the target instance id + /// @param updateTarget the existing instance selecting update mode, or `null` for install + /// @throws IllegalArgumentException if an update target has no compatible configuration + /// @throws IllegalStateException if the target cannot be reserved, an update target is not + /// the exact published object, or another draft is open + private ServerModpackRemoteInstallTask( + DefaultDependencyManager dependencyManager, + ServerModpackManifest manifest, + GameInstanceID instanceId, + @Nullable DefaultGameInstance updateTarget) { this.instanceId = instanceId; + this.updateTarget = updateTarget; this.dependency = dependencyManager; this.repository = dependencyManager.getGameRepository(); this.manifest = manifest; Path json = repository.getLayout().getModpackConfigurationFile(instanceId); - if (repository.hasInstance(instanceId) && Files.notExists(json)) - throw new IllegalArgumentException("Instance " + instanceId + " already exists."); - - GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); - for (ServerModpackManifest.Addon addon : manifest.getAddons()) { - @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); - if (componentType != null) - builder.component(componentType, addon.getVersion()); + if (this.updateTarget != null && Files.notExists(json)) + throw new IllegalArgumentException("Instance " + instanceId + " is not a Server modpack. Cannot update this instance."); + + try { + if (this.updateTarget != null && Files.exists(json)) { + @Nullable ModpackConfiguration config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(ServerModpackManifest.class)); + + if (config == null || !MODPACK_TYPE.equals(config.getType())) + throw new IllegalArgumentException("Instance " + instanceId + " is not a Server modpack. Cannot update this instance."); + } + } catch (JsonParseException | IOException ignore) { } - dependents.add(builder.buildAsync()); onDone().register(event -> { - if (event.isFailed()) + if (this.updateTarget == null && event.isFailed()) repository.removeInstanceFromDisk(instanceId); }); - ModpackConfiguration config; - try { - if (Files.exists(json)) { - config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(ServerModpackManifest.class)); - - if (!MODPACK_TYPE.equals(config.getType())) - throw new IllegalArgumentException("Instance " + instanceId + " is not a Server modpack. Cannot update this instance."); + try (GameBuilder builder = this.updateTarget == null + ? dependencyManager.newGameBuilder(instanceId) + : dependencyManager.newGameBuilder(this.updateTarget)) { + builder.enableIsolation(); + for (ServerModpackManifest.Addon addon : manifest.getAddons()) { + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); } - } catch (JsonParseException | IOException ignore) { + dependents.add(builder.buildAsync()); } } 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 cc66267d1be..ea25b1a94b6 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,6 @@ */ package org.jackhuang.hmcl.util; -import org.jackhuang.hmcl.download.RemoteVersion; -import org.jackhuang.hmcl.game.GameComponentType; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -89,17 +87,6 @@ public void clear() { map.clear(); } - /// Returns whether the selected installation includes any non-vanilla component. - public boolean isInstallingModdedVersion() { - for (GameComponentType value : GameComponentType.MOD_LOADERS) { - if (get(value.getPatchId()) instanceof RemoteVersion) { - return true; - } - } - - return false; - } - @Override public String toString() { return "SettingsMap" + map; diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java index ab9df59627f..71856a047d0 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -19,14 +19,19 @@ import org.jackhuang.hmcl.download.DefaultCacheRepository; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.DefaultGameBuilder; 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.Modpack; 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.multimc.MultiMCInstanceConfiguration; +import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackInstallTask; import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask; +import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -41,6 +46,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -69,7 +75,7 @@ public void testModLauncherLaunchRepairDoesNotWriteBundledLibraries(@TempDir Pat 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(); + GameInstanceManifest launchManifest = instance.getResolvedManifest(); assertTrue(launchManifest.getLibraries().stream() .noneMatch(library -> library.is( "org.jackhuang.hmcl", "transformer-discovery-service"))); @@ -86,6 +92,46 @@ public void testModLauncherLaunchRepairDoesNotWriteBundledLibraries(@TempDir Pat assertEquals(repaired, LaunchManifestNormalizer.repairForLaunch(repaired)); } + /// Repairs a missing patch-installed OptiFine library using the patch's self version. + @Test + public void testPatchCompletionUsesStoredOptiFineVersion(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultCacheRepository cacheRepository = new DefaultCacheRepository(tempDirectory.resolve("cache")); + CapturingDependencyManager dependencyManager = new CapturingDependencyManager(repository, cacheRepository); + GameInstanceID instanceId = new GameInstanceID("instance"); + String gameVersion = "1.21.1"; + String optiFineVersion = "HD_U_I9"; + GameInstancePatch gamePatch = new GameInstancePatch( + GameComponentType.GAME.getPatchId(), + gameVersion, + GameInstancePatch.PRIORITY_MC, + null, + GameComponentAnalyzer.VANILLA_MAIN, + List.of()); + GameInstancePatch optiFinePatch = new GameInstancePatch( + GameComponentType.OPTIFINE.getPatchId(), + optiFineVersion, + GameInstancePatch.PRIORITY_LOADER, + null, + null, + List.of(new Library("optifine", "OptiFine", gameVersion + "_" + optiFineVersion))); + GameInstanceManifest storedManifest = new GameInstanceManifest(instanceId) + .withRoot(true) + .withPatches(List.of(gamePatch, optiFinePatch)) + .reconstructByPatches(); + writeVersionJar(repository.getLayout().getInstanceJarFile(instanceId), gameVersion); + TestGameInstance instance = repository.publish(instanceId, storedManifest); + + Task repair = dependencyManager.checkPatchCompletionAsync( + instance, + LaunchManifestNormalizer.repairForLaunch(instance.getResolvedManifest()), + true); + + assertTrue(repair.executor().test()); + assertEquals(gameVersion, dependencyManager.requestedGameVersion); + assertEquals(optiFineVersion, dependencyManager.requestedComponentVersion); + } + /// Saving a manifest preserves its root flag and pending patches without baking in normalization. @Test public void testSavePreservesManifestPatchStructure(@TempDir Path tempDirectory) throws Exception { @@ -183,24 +229,6 @@ public void testSnapshotCopyDoesNotShareAddonManagers(@TempDir Path tempDirector 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) @@ -377,12 +405,165 @@ public void testDependencyManagerValidatesInstanceRepository(@TempDir Path tempD new DefaultCacheRepository(tempDirectory.resolve("cache"))); assertThrows(IllegalArgumentException.class, () -> dependencyManager.validateGameInstance(instance)); + assertThrows(IllegalArgumentException.class, () -> dependencyManager.newGameBuilder(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)); } + /// A new-install builder rejects an id that is already present when its draft opens. + @Test + public void testGameBuilderRejectsNewInstallationOverExistingInstance(@TempDir Path tempDirectory) + throws IOException { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + GameInstanceID instanceId = new GameInstanceID("instance"); + repository.publish(instanceId, new GameInstanceManifest(instanceId)); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("cache"))); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> dependencyManager.newGameBuilder(instanceId)); + + assertEquals("Game instance already exists: instance", exception.getMessage()); + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertTrue(draft.isOpen()); + } + } + + /// An update builder rejects a target that disappeared before construction. + @Test + public void testGameBuilderRejectsMissingUpdateTarget(@TempDir Path tempDirectory) + throws IOException { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + GameInstanceID instanceId = new GameInstanceID("instance"); + TestGameInstance instance = repository.publish( + instanceId, + new GameInstanceManifest(instanceId)); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("cache"))); + repository.publishEmpty(); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> dependencyManager.newGameBuilder(instance)); + + assertEquals("Game instance no longer exists: instance", exception.getMessage()); + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertTrue(draft.isOpen()); + } + } + + /// An update builder rejects a snapshot-bound instance replaced under the same id. + @Test + public void testGameBuilderRejectsChangedUpdateTarget(@TempDir Path tempDirectory) + throws IOException { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + GameInstanceID instanceId = new GameInstanceID("instance"); + TestGameInstance instance = repository.publish( + instanceId, + new GameInstanceManifest(instanceId)); + repository.publish(instanceId, new GameInstanceManifest(instanceId)); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("cache"))); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> dependencyManager.newGameBuilder(instance)); + + assertEquals("Game instance has changed: instance", exception.getMessage()); + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertTrue(draft.isOpen()); + } + } + + /// Closing an unused builder releases its exclusive draft and prevents further configuration. + @Test + public void testGameBuilderCloseAbortsReservedDraft(@TempDir Path tempDirectory) + throws IOException { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("cache"))); + DefaultGameBuilder builder = dependencyManager.newGameBuilder(new GameInstanceID("instance")); + + assertThrows(IllegalStateException.class, repository::openDraft); + builder.close(); + builder.close(); + + IllegalStateException exception = assertThrows( + IllegalStateException.class, + () -> builder.component(GameComponentType.GAME, "1.21.1")); + assertEquals("GameBuilder is closed", exception.getMessage()); + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertTrue(draft.isOpen()); + } + } + + /// A synchronous build-configuration failure aborts the builder's reserved draft. + @Test + public void testGameBuilderBuildFailureAbortsReservedDraft(@TempDir Path tempDirectory) + throws IOException { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("cache"))); + DefaultGameBuilder builder = dependencyManager.newGameBuilder(new GameInstanceID("instance")); + + IllegalStateException exception = assertThrows(IllegalStateException.class, builder::buildAsync); + + assertEquals("GameBuilder.gameVersion must be set", exception.getMessage()); + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertTrue(draft.isOpen()); + } + } + + /// A MultiMC task declared as a new installation rejects an existing id without deleting it. + @Test + public void testMultiMCInstallTaskDoesNotInferUpdateFromExistingInstance( + @TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + GameInstanceID instanceId = new GameInstanceID("instance"); + TestGameInstance instance = repository.publish( + instanceId, + new GameInstanceManifest(instanceId)); + Path marker = instance.getInstanceRoot().resolve("marker.txt"); + Files.createDirectories(marker.getParent()); + Files.writeString(marker, "existing"); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("cache"))); + MultiMCInstanceConfiguration manifest = createMultiMCConfiguration(); + Modpack modpack = createMultiMCModpack(manifest); + MultiMCModpackInstallTask task = new MultiMCModpackInstallTask( + dependencyManager, + tempDirectory.resolve("modpack.zip"), + modpack, + manifest, + instanceId); + + assertFalse(task.executor().test()); + + assertEquals( + "Game instance already exists: instance", + Objects.requireNonNull(task.getException()).getMessage()); + assertSame(instance, repository.getInstance(instanceId)); + assertEquals("existing", Files.readString(marker)); + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertTrue(draft.isOpen()); + } + } + /// Non-conventional JSON/jar basenames are kept on disk and recorded on the instance. @Test public void testRefreshRecordsNonConventionalStoragePaths(@TempDir Path tempDirectory) throws IOException { @@ -404,7 +585,6 @@ public void testRefreshRecordsNonConventionalStoragePaths(@TempDir Path tempDire 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]. @@ -485,6 +665,56 @@ private static boolean hasZipEntry(Path zipFile, String entryName) throws IOExce } } + /// Creates minimal MultiMC settings for testing mode validation before archive access. + /// + /// @return the test configuration + private static MultiMCInstanceConfiguration createMultiMCConfiguration() { + return new MultiMCInstanceConfiguration( + "OneSix", + "Test", + "1.21.1", + 0, + "", + "", + "", + "", + "", + "", + false, + 0, + 0, + 0, + 0, + false, + false, + false, + false, + false, + false, + false, + false, + false, + ""); + } + + /// Creates modpack metadata backed by the given MultiMC configuration. + /// + /// @param manifest the MultiMC configuration + /// @return the test modpack metadata + private static Modpack createMultiMCModpack(MultiMCInstanceConfiguration manifest) { + return new Modpack("Test", "", "", manifest.getGameVersion(), "", StandardCharsets.UTF_8, manifest) { + /// This test invokes the concrete installation task directly. + @Override + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + GameInstanceID instanceId, + String iconUrl) { + throw new UnsupportedOperationException(); + } + }; + } + /// Minimal repository implementation for snapshot-bound instance tests. @NotNullByDefault private static final class TestRepository extends DefaultGameRepository { @@ -538,6 +768,11 @@ private TestGameInstance publish( return instance; } + /// Publishes an empty snapshot to simulate removal before builder construction. + private void publishEmpty() { + publishSnapshot(newSnapshot()); + } + /// Creates an empty mutable snapshot using the current layout. /// /// @return the new snapshot @@ -546,6 +781,48 @@ private DefaultGameRepositorySnapshot newSnapshot() { } } + /// Dependency manager that records remote component repair requests. + @NotNullByDefault + private static final class CapturingDependencyManager extends DefaultDependencyManager { + + /// Minecraft version passed to the captured repair request. + private @Nullable String requestedGameVersion; + + /// Component version passed to the captured repair request. + private @Nullable String requestedComponentVersion; + + /// Creates a capturing manager for the test repository. + /// + /// @param repository the target repository + /// @param cacheRepository the test download cache + private CapturingDependencyManager( + DefaultGameRepository repository, + DefaultCacheRepository cacheRepository) { + super(repository, new MojangDownloadProvider(), cacheRepository); + } + + /// Records the requested remote version without performing a network lookup. + /// + /// @param instance the registered instance being modified + /// @param baseManifest the working manifest for this step + /// @param gameVersion the Minecraft version used for the lookup + /// @param componentType the component list id + /// @param componentVersion the component version id + /// @return a completed task containing `baseManifest` + @Override + public Task installComponentRemoteAsync( + GameInstance instance, + GameInstanceManifest baseManifest, + String gameVersion, + GameComponentType componentType, + String componentVersion) { + assertEquals(GameComponentType.OPTIFINE, componentType); + requestedGameVersion = gameVersion; + requestedComponentVersion = componentVersion; + return Task.completed(baseManifest); + } + } + /// Minimal concrete game instance that exposes cache state to tests. @NotNullByDefault private static final class TestGameInstance extends DefaultGameInstance { diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java index 5ca62408a56..1b156b2ccc4 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java @@ -310,6 +310,38 @@ public void testUpdateInstanceAsyncRejectsChangedId(@TempDir Path tempDirectory) } } + /// Releases the draft when the target disappears before the updater is invoked. + @Test + public void testUpdateInstanceAsyncAbortsDraftWhenTargetIsMissing(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("missing"); + + Task update = repository.updateInstanceAsync(id, workingInstance -> + Task.completed(workingInstance.getManifest())); + assertFalse(update.executor().test()); + + try (DefaultGameRepositoryDraft ignored = repository.openDraft()) { + assertTrue(ignored.isOpen()); + } + } + + /// Releases the draft when the updater throws before returning its task. + @Test + public void testUpdateInstanceAsyncAbortsDraftWhenUpdaterThrows(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + repository.save(new GameInstanceManifest(id)); + + Task update = repository.updateInstanceAsync(id, workingInstance -> { + throw new IOException("Simulated updater failure"); + }); + assertFalse(update.executor().test()); + + try (DefaultGameRepositoryDraft ignored = repository.openDraft()) { + assertTrue(ignored.isOpen()); + } + } + /// Minimal repository implementation for draft tests. @NotNullByDefault private static class TestRepository extends DefaultGameRepository { 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 48c46b9c5ad..56c5ab63b64 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java @@ -22,7 +22,6 @@ import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; -import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Objects; @@ -44,65 +43,6 @@ public void testNullableBooleanFieldsAreNotMaterialized() { assertFalse(json.has("hidden")); } - /// Root manifests with patch lists resolve from the patch view instead of their own body fields. - @Test - public void testRootManifestWithPatchesUsesPatchView() throws NoSuchGameInstanceException { - GameInstanceManifest manifest = manifest( - "example", - "example.Main", - true, - false, - List.of(patch("patch", null))); - - 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()); - assertEquals(List.of(patch("patch", null)), resolved.standaloneManifest().getPatches()); - } - /// Manifest edits update known raw JSON fields without discarding unknown fields. @Test public void testRawJsonIsPreservedWhenUpdatingKnownFields() { diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java deleted file mode 100644 index c04c1adebe3..00000000000 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java +++ /dev/null @@ -1,57 +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.util; - -import org.jackhuang.hmcl.download.RemoteVersion; -import org.jackhuang.hmcl.game.GameComponentType; -import org.jetbrains.annotations.NotNullByDefault; -import org.junit.jupiter.api.Test; - -import java.time.Instant; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -/// Tests for installer state stored in [SettingsMap]. -@NotNullByDefault -public final class SettingsMapTest { - /// Tests that vanilla game selection alone is not treated as a modded installation. - @Test - public void minecraftSelectionIsNotModdedInstallation() { - SettingsMap settings = new SettingsMap(); - settings.put(GameComponentType.GAME.getPatchId(), remoteVersion(GameComponentType.GAME)); - - assertFalse(settings.isInstallingModdedVersion()); - } - - /// Tests that selecting a non-vanilla installer is treated as a modded installation. - @Test - public void modLoaderSelectionIsModdedInstallation() { - SettingsMap settings = new SettingsMap(); - 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(GameComponentType componentType) { - return new RemoteVersion(componentType, "1.21.11", "test", Instant.EPOCH, List.of()); - } -}