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 extends HMCLGameInstance.Optional> 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 extends Task>> 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 extends Task>> 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 extends DefaultGameRepositorySnapshot> 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());
- }
-}