From 79e803aaa5ec651e5dbbdb374e913835d0551d72 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 2 Aug 2026 20:46:01 +0800 Subject: [PATCH 001/199] feat: implement DefaultGameRepositoryLayout for improved directory structure management --- .../hmcl/game/DefaultGameRepository.java | 27 +++-- .../game/DefaultGameRepositoryLayout.java | 107 ++++++++++++++++++ .../jackhuang/hmcl/game/GameRepository.java | 3 +- .../hmcl/game/GameRepositoryLayout.java | 86 ++++++++++++++ 4 files changed, 211 insertions(+), 12 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index a9ff493d382..5255b40e1a4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -93,19 +93,24 @@ private static boolean hasClassicVersion(Path baseDirectory) { private final ConcurrentHashMap> gameVersions = new ConcurrentHashMap<>(); public DefaultGameRepository(Path baseDirectory) { - this.status = new Status(baseDirectory); + this.status = new Status(new DefaultGameRepositoryLayout(baseDirectory)); } public Path getBaseDirectory() { - return status.baseDirectory; + return status.layout.getBaseDirectory(); } public void setBaseDirectory(Path baseDirectory) { - this.status = new Status(baseDirectory); + this.status = new Status(new DefaultGameRepositoryLayout(baseDirectory)); this.loaded = false; this.gameVersions.clear(); } + @Override + public GameRepositoryLayout getLayout() { + return status.layout; + } + public boolean isLoaded() { return loaded; } @@ -122,14 +127,14 @@ public void refresh() { } protected void refreshImpl() { - Status newStatus = new Status(status.baseDirectory); + Status newStatus = new Status(status.layout); - if (hasClassicVersion(newStatus.baseDirectory)) { + if (hasClassicVersion(newStatus.layout.getBaseDirectory())) { GameInstanceID id = CLASSIC_MANIFEST.id(); newStatus.instances.put(id, new InstanceHolder(newStatus, id, CLASSIC_MANIFEST)); } - Path versionsDir = newStatus.baseDirectory.resolve("versions"); + Path versionsDir = newStatus.layout.getBaseDirectory().resolve("versions"); if (Files.isDirectory(versionsDir)) { try (Stream stream = Files.list(versionsDir)) { stream.parallel().filter(Files::isDirectory).flatMap(dir -> { @@ -189,7 +194,7 @@ protected void refreshImpl() { if (!id.equals(manifest.id())) { try { - moveInstanceFiles(newStatus.baseDirectory, id, manifest.id()); + moveInstanceFiles(newStatus.layout.getBaseDirectory(), id, manifest.id()); } catch (IOException e) { LOG.warning("Ignoring instance " + manifest.id() + " because instance id does not match folder name " + id @@ -359,7 +364,7 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { throw new NoSuchGameInstanceException(from); } - moveInstanceFiles(currentStatus.baseDirectory, from, to); + moveInstanceFiles(currentStatus.layout.getBaseDirectory(), from, to); GameInstanceManifest renamedManifest = fromHolder.manifest; if (from.equals(renamedManifest.jar())) { @@ -635,11 +640,11 @@ public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) thro } protected static class Status { - private final Path baseDirectory; + private final DefaultGameRepositoryLayout layout; private final Map instances = new TreeMap<>(); - protected Status(Path baseDirectory) { - this.baseDirectory = baseDirectory; + protected Status(DefaultGameRepositoryLayout layout) { + this.layout = layout; } private GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java new file mode 100644 index 00000000000..9dc93777f03 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java @@ -0,0 +1,107 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jetbrains.annotations.NotNullByDefault; + +import java.nio.file.Path; +import java.util.Objects; + +/// Implements the conventional Minecraft repository directory layout. +@NotNullByDefault +public final class DefaultGameRepositoryLayout implements GameRepositoryLayout { + private final Path baseDirectory; + + /// Creates a layout rooted at the given directory. + /// + /// The path is retained as supplied and is not normalized or converted to an absolute path. + /// + /// @param baseDirectory the repository base directory + public DefaultGameRepositoryLayout(Path baseDirectory) { + this.baseDirectory = Objects.requireNonNull(baseDirectory); + } + + public Path getBaseDirectory() { + return baseDirectory; + } + + /// {@inheritDoc} + @Override + public Path getInstanceRoot(GameInstanceID instanceId) { + return getBaseDirectory().resolve("versions").resolve(instanceId.id()); + } + + /// {@inheritDoc} + @Override + public Path getInstanceJson(GameInstanceID instanceId) { + return getInstanceRoot(instanceId).resolve(instanceId.id() + ".json"); + } + + /// {@inheritDoc} + @Override + public Path getInstanceJarFile(GameInstanceID instanceId) { + return getInstanceRoot(instanceId).resolve(instanceId.id() + ".jar"); + } + + /// {@inheritDoc} + @Override + public Path getLibrariesDirectory() { + return getBaseDirectory().resolve("libraries"); + } + + /// {@inheritDoc} + @Override + public Path getLibraryFile(GameInstanceID owner, Library library) { + if ("local".equals(library.hint())) { + if (library.filename() != null) { + return getInstanceRoot(owner).resolve("libraries").resolve(library.filename()); + } + + return getInstanceRoot(owner).resolve("libraries").resolve(library.artifact().getFileName()); + } + + return getLibrariesDirectory().resolve(library.getPath()); + } + + /// {@inheritDoc} + @Override + public Path getAssetDirectory() { + return getBaseDirectory().resolve("assets"); + } + + /// {@inheritDoc} + @Override + public Path getAssetIndexFile(String assetId) { + return getAssetDirectory().resolve("indexes").resolve(assetId + ".json"); + } + + /// {@inheritDoc} + @Override + public Path getAssetObject(AssetObject object) { + return getAssetDirectory().resolve("objects").resolve(object.getLocation()); + } + + /// {@inheritDoc} + /// + /// The conventional layout stores logging configurations in a shared directory, so + /// `assetId` does not alter the returned path. + @Override + public Path getLoggingObject(String assetId, LoggingInfo loggingInfo) { + return getAssetDirectory().resolve("log_configs").resolve(loggingInfo.file().getId()); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java index 5949fac8d57..0d74e6e91dc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -36,6 +36,8 @@ /// locating instance-owned files, and exposing helper paths used by launch, download, and maintenance code. @NotNullByDefault public interface GameRepository { + GameRepositoryLayout getLayout(); + /// Resolves inheritance into launch and standalone manifest views. /// /// @param manifest the manifest to resolve @@ -233,5 +235,4 @@ default Set getClasspath(GameInstanceManifest manifest) { return classpath; } - } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java new file mode 100644 index 00000000000..f36d11ce101 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java @@ -0,0 +1,86 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jetbrains.annotations.NotNullByDefault; + +import java.nio.file.Path; + +/// Computes repository paths without performing filesystem I/O. +/// +/// Implementations must be immutable. Returned paths are derived solely from the layout's base +/// directory and the supplied arguments, so callers may safely share a layout between threads. +@NotNullByDefault +public interface GameRepositoryLayout { + /// Returns the directory containing the files owned by an instance. + /// + /// @param instanceId the instance ID + /// @return the instance root directory + Path getInstanceRoot(GameInstanceID instanceId); + + /// Returns the manifest file for an instance. + /// + /// @param instanceId the instance ID + /// @return the path `versions//.json` below the base directory + Path getInstanceJson(GameInstanceID instanceId); + + /// Returns the conventional client jar file for an instance. + /// + /// @param instanceId the instance ID + /// @return the path `versions//.jar` below the base directory + Path getInstanceJarFile(GameInstanceID instanceId); + + /// Returns the shared libraries directory. + /// + /// @return the path `libraries` below the base directory + Path getLibrariesDirectory(); + + /// Returns the file used for a library referenced by an instance. + /// + /// Libraries with the `local` hint are resolved below the owning instance's `libraries` + /// directory. Other libraries are resolved below the shared libraries directory. + /// + /// @param owner the ID of the instance that owns the library reference + /// @param library the library descriptor + /// @return the library file path + Path getLibraryFile(GameInstanceID owner, Library library); + + /// Returns the shared asset directory. + /// + /// @return the path `assets` below the base directory + Path getAssetDirectory(); + + /// Returns the file containing an asset index. + /// + /// @param assetId the asset index ID + /// @return the asset index file path + Path getAssetIndexFile(String assetId); + + /// Returns the content-addressed file for an asset object. + /// + /// @param object the asset object descriptor + /// @return the asset object file path + Path getAssetObject(AssetObject object); + + /// Returns the file containing a logging configuration object. + /// + /// @param assetId the asset index ID associated with the launch manifest + /// @param loggingInfo the logging configuration descriptor + /// @return the logging configuration file path + Path getLoggingObject(String assetId, LoggingInfo loggingInfo); +} From c0f6fba96512181588c631a9f14e7a9627fd2054 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 2 Aug 2026 20:54:45 +0800 Subject: [PATCH 002/199] Move pure path resolution from DefaultGameRepository to GameRepository layout defaults Assisted-by: grok-build:grok-4.5 --- .../hmcl/game/DefaultGameRepository.java | 54 ++----------------- .../game/DefaultGameRepositoryLayout.java | 2 +- .../jackhuang/hmcl/game/GameRepository.java | 48 ++++++++++++++--- 3 files changed, 45 insertions(+), 59 deletions(-) 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 5255b40e1a4..22e3f5d9782 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -302,36 +302,13 @@ public int getInstanceCount() { return status.instances.size(); } - @Override - public Path getInstanceRoot(GameInstanceID instanceId) { - return getBaseDirectory().resolve("versions").resolve(instanceId.id()); - } - @Override public Collection getInstanceManifests() { return status.instances.values().stream().map(i -> i.manifest).toList(); } - @Override - public Path getLibrariesDirectory(GameInstanceManifest manifest) { - return getBaseDirectory().resolve("libraries"); - } - - @Override - public Path getLibraryFile(GameInstanceManifest manifest, Library lib) { - if ("local".equals(lib.hint())) { - if (lib.filename() != null) { - return getInstanceRoot(manifest.id()).resolve("libraries/" + lib.filename()); - } - - return getInstanceRoot(manifest.id()).resolve("libraries/" + lib.artifact().getFileName()); - } - - return getLibrariesDirectory(manifest).resolve(lib.getPath()); - } - public Path getArtifactFile(GameInstanceManifest manifest, Artifact artifact) { - return artifact.getPath(getBaseDirectory().resolve("libraries")); + return artifact.getPath(getLayout().getLibrariesDirectory()); } @Override @@ -339,16 +316,11 @@ public Path getRunDirectory(GameInstanceID instanceId) { return getBaseDirectory(); } - @Override - public Path getInstanceJar(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getInstanceJar(getResolvedInstanceManifest(instanceId).launchManifest()); - } - @Override public Path getInstanceJar(GameInstanceManifest manifest) { GameInstanceManifest resolved = this.resolve(manifest).launchManifest(); GameInstanceID id = Optional.ofNullable(resolved.jar()).orElse(resolved.id()); - return getInstanceRoot(id).resolve(id + ".jar"); + return getLayout().getInstanceJarFile(id); } @Override @@ -478,7 +450,7 @@ public Path getResourcePackDirectory(GameInstanceID instanceId) { } public Path getInstanceJson(GameInstanceID instanceId) { - return getInstanceRoot(instanceId).resolve(instanceId.id() + ".json"); + return getLayout().getInstanceJson(instanceId); } @Override @@ -500,11 +472,6 @@ public Path getActualAssetDirectory(GameInstanceID instanceId, String assetId) { } } - @Override - public Path getAssetDirectory(GameInstanceID instanceId, String assetId) { - return getBaseDirectory().resolve("assets"); - } - @Override public Optional getAssetObject(GameInstanceID instanceId, String assetId, String name) throws IOException { try { @@ -518,25 +485,10 @@ public Optional getAssetObject(GameInstanceID instanceId, String assetId, } } - @Override - public Path getAssetObject(GameInstanceID instanceId, String assetId, AssetObject obj) { - return getAssetObject(instanceId, getAssetDirectory(instanceId, assetId), obj); - } - public Path getAssetObject(GameInstanceID instanceId, Path assetDir, AssetObject obj) { return assetDir.resolve("objects").resolve(obj.getLocation()); } - @Override - public Path getIndexFile(GameInstanceID instanceId, String assetId) { - return getAssetDirectory(instanceId, assetId).resolve("indexes").resolve(assetId + ".json"); - } - - @Override - public Path getLoggingObject(GameInstanceID instanceId, String assetId, LoggingInfo loggingInfo) { - return getAssetDirectory(instanceId, assetId).resolve("log_configs").resolve(loggingInfo.file().getId()); - } - protected Path reconstructAssets(GameInstanceID instanceId, String assetId) throws IOException, JsonParseException { Path assetsDir = getAssetDirectory(instanceId, assetId); Path indexFile = getIndexFile(instanceId, assetId); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java index 9dc93777f03..0f1dedbe265 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java @@ -24,7 +24,7 @@ /// Implements the conventional Minecraft repository directory layout. @NotNullByDefault -public final class DefaultGameRepositoryLayout implements GameRepositoryLayout { +public class DefaultGameRepositoryLayout implements GameRepositoryLayout { private final Path baseDirectory; /// Creates a layout rooted at the given directory. 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 0d74e6e91dc..39b16d553d3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -85,9 +85,13 @@ default Task refreshAsync() { /// Returns the directory that stores files belonging to an instance. /// + /// Delegates to [GameRepositoryLayout#getInstanceRoot(GameInstanceID)]. + /// /// @param instanceId the instance id /// @return the instance root directory - Path getInstanceRoot(GameInstanceID instanceId); + default Path getInstanceRoot(GameInstanceID instanceId) { + return getLayout().getInstanceRoot(instanceId); + } /// Returns the working directory used when launching an instance. /// @@ -97,16 +101,26 @@ default Task refreshAsync() { /// Returns the base directory used to store shared libraries for a manifest. /// + /// Delegates to [GameRepositoryLayout#getLibrariesDirectory()]. The manifest argument is + /// retained for API compatibility and is not used by the default implementation. + /// /// @param manifest the manifest whose libraries are being resolved /// @return the libraries directory - Path getLibrariesDirectory(GameInstanceManifest manifest); + default Path getLibrariesDirectory(GameInstanceManifest manifest) { + return getLayout().getLibrariesDirectory(); + } /// Returns the expected filesystem path for a library. /// + /// Delegates to [GameRepositoryLayout#getLibraryFile(GameInstanceID, Library)] using + /// [GameInstanceManifest#id()] as the library owner. + /// /// @param manifest the manifest that owns or references the library /// @param lib the library descriptor /// @return the library file path - Path getLibraryFile(GameInstanceManifest manifest, Library lib); + default Path getLibraryFile(GameInstanceManifest manifest, Library lib) { + return getLayout().getLibraryFile(manifest.id(), lib); + } /// Returns the directory used for extracted native libraries of an instance and platform. /// @@ -173,10 +187,15 @@ default Path getInstanceJar(GameInstanceID instanceId) throws NoSuchGameInstance /// Returns the base asset storage directory for an instance. /// + /// Delegates to [GameRepositoryLayout#getAssetDirectory()]. The instance and asset id + /// arguments are retained for API compatibility and are not used by the default implementation. + /// /// @param instanceId the instance id /// @param assetId the asset index id /// @return the asset storage directory - Path getAssetDirectory(GameInstanceID instanceId, String assetId); + default Path getAssetDirectory(GameInstanceID instanceId, String assetId) { + return getLayout().getAssetDirectory(); + } /// Returns an existing asset object path by logical asset name. /// @@ -189,11 +208,16 @@ default Path getInstanceJar(GameInstanceID instanceId) throws NoSuchGameInstance /// Returns the expected path for an asset object descriptor. /// + /// Delegates to [GameRepositoryLayout#getAssetObject(AssetObject)]. The instance and asset id + /// arguments are retained for API compatibility and are not used by the default implementation. + /// /// @param instanceId the instance id /// @param assetId the asset index id /// @param obj the asset object descriptor /// @return the asset object path - Path getAssetObject(GameInstanceID instanceId, String assetId, AssetObject obj); + default Path getAssetObject(GameInstanceID instanceId, String assetId, AssetObject obj) { + return getLayout().getAssetObject(obj); + } /// Reads an asset index. /// @@ -205,18 +229,28 @@ default Path getInstanceJar(GameInstanceID instanceId) throws NoSuchGameInstance /// Returns the path of an asset index file. /// + /// Delegates to [GameRepositoryLayout#getAssetIndexFile(String)]. The instance id is retained + /// for API compatibility and is not used by the default implementation. + /// /// @param instanceId the instance id /// @param assetId the asset index id /// @return the asset index file path - Path getIndexFile(GameInstanceID instanceId, String assetId); + default Path getIndexFile(GameInstanceID instanceId, String assetId) { + return getLayout().getAssetIndexFile(assetId); + } /// Returns the path of a logging configuration object. /// + /// Delegates to [GameRepositoryLayout#getLoggingObject(String, LoggingInfo)]. The instance id is + /// retained for API compatibility and is not used by the default implementation. + /// /// @param instanceId the instance id /// @param assetId the asset index id used as the logging object namespace /// @param loggingInfo the logging configuration descriptor /// @return the logging object path - Path getLoggingObject(GameInstanceID instanceId, String assetId, LoggingInfo loggingInfo); + default Path getLoggingObject(GameInstanceID instanceId, String assetId, LoggingInfo loggingInfo) { + return getLayout().getLoggingObject(assetId, loggingInfo); + } /// Returns the classpath entries whose library files are present on disk. /// From a7c542fc4f64324680fe9e5697a86766c1133034 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 2 Aug 2026 20:56:20 +0800 Subject: [PATCH 003/199] refactor: update repository path resolution to use GameRepositoryLayout --- .../jackhuang/hmcl/game/HMCLGameLauncher.java | 2 +- .../hmcl/game/HMCLGameRepository.java | 26 +++--- .../jackhuang/hmcl/game/LauncherHelper.java | 4 +- .../setting/LegacyGameSettingsMigrator.java | 2 +- .../hmcl/ui/game/GameSettingsPage.java | 2 +- .../hmcl/ui/instances/Instances.java | 8 +- .../hmcl/setting/GameDirectoriesTest.java | 11 +-- .../download/DefaultDependencyManager.java | 7 +- .../jackhuang/hmcl/download/MaintainTask.java | 6 +- .../download/forge/ForgeNewInstallTask.java | 12 +-- .../download/forge/ForgeOldInstallTask.java | 4 +- .../download/game/GameAssetDownloadTask.java | 7 +- .../game/GameAssetIndexDownloadTask.java | 9 +- .../hmcl/download/game/GameLibrariesTask.java | 4 +- .../neoforge/NeoForgeOldInstallTask.java | 4 +- .../optifine/OptiFineInstallTask.java | 8 +- .../hmcl/game/DefaultGameRepository.java | 16 ++-- .../jackhuang/hmcl/game/GameRepository.java | 85 +------------------ .../hmcl/launch/DefaultLauncher.java | 10 +-- .../hmcl/modpack/ModpackUpdateTask.java | 4 +- .../modpack/curse/CurseCompletionTask.java | 7 +- .../hmcl/modpack/curse/CurseInstallTask.java | 4 +- .../mcbbs/McbbsModpackCompletionTask.java | 2 +- .../modrinth/ModrinthCompletionTask.java | 2 +- .../modpack/modrinth/ModrinthInstallTask.java | 2 +- .../multimc/MultiMCModpackInstallTask.java | 12 +-- .../server/ServerModpackCompletionTask.java | 2 +- 27 files changed, 87 insertions(+), 175 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index c21dae06ef3..8dd12d8432e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -180,7 +180,7 @@ private Path extractLwjglUnsafeAgent() throws IOException { Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion)); String fileName = library.artifact().getFileName(); - Path agentPath = repository.getLibraryFile(manifest, library).toAbsolutePath().normalize(); + Path agentPath = repository.getLayout().getLibraryFile(manifest.id(), library).toAbsolutePath().normalize(); if (agentPath.toString().contains("=")) { throw new IOException("Invalid library path: " + agentPath); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java index 0b704310602..76f105850db 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -165,7 +165,7 @@ public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) @Override public Path getRunDirectory(GameInstanceID instanceId) { if (beingModpackInstances.contains(instanceId) || isModpack(instanceId)) { - return getInstanceRoot(instanceId); + return getLayout().getInstanceRoot(instanceId); } GameSettings.Instance localSetting = getInstanceGameSettings(instanceId); @@ -174,13 +174,13 @@ public Path getRunDirectory(GameInstanceID instanceId) { String runningDirectory = getSelectedRunningDirectory(localSetting, useInstanceRunningDirectory); if (StringUtils.isBlank(runningDirectory)) { - return useInstanceRunningDirectory ? getInstanceRoot(instanceId) : super.getRunDirectory(instanceId); + return useInstanceRunningDirectory ? getLayout().getInstanceRoot(instanceId) : super.getRunDirectory(instanceId); } try { return Path.of(runningDirectory); } catch (InvalidPathException ignored) { - return getInstanceRoot(instanceId); + return getLayout().getInstanceRoot(instanceId); } } @@ -257,8 +257,8 @@ public boolean removeInstanceFromDisk(GameInstanceID instanceId) { } public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException { - Path srcDir = getInstanceRoot(srcId); - Path dstDir = getInstanceRoot(dstId); + Path srcDir = getLayout().getInstanceRoot(srcId); + Path dstDir = getLayout().getInstanceRoot(dstId); GameInstanceManifest fromManifest = getInstanceManifest(srcId); @@ -287,7 +287,7 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea boolean copyOriginalGameDir; try { - copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getInstanceRoot(srcId)); + copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getLayout().getInstanceRoot(srcId)); } catch (IOException e) { copyOriginalGameDir = true; } @@ -321,7 +321,7 @@ private GameSettings.Instance copyInstanceGameSettings(GameInstanceID instanceId /// /// This directory stores instance-scoped files owned by HMCL. public Path getInstanceMetadataDirectory(GameInstanceID instanceId) { - return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY); + return getLayout().getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY); } /// Returns the HMCL-managed configuration directory under the instance metadata directory. @@ -589,7 +589,7 @@ public void applyDefaultIsolationSettingForNewInstance(GameInstanceID instanceId } public Optional getInstanceIconFile(GameInstanceID instanceId) { - Path root = getInstanceRoot(instanceId); + Path root = getLayout().getInstanceRoot(instanceId); for (String extension : FXUtils.IMAGE_EXTENSIONS) { Path file = root.resolve("icon." + extension); @@ -609,11 +609,11 @@ public void setInstanceIconFile(GameInstanceID instanceId, Path iconFile) throws deleteIconFile(instanceId); - FileUtils.copyFile(iconFile, getInstanceRoot(instanceId).resolve("icon." + ext)); + FileUtils.copyFile(iconFile, getLayout().getInstanceRoot(instanceId).resolve("icon." + ext)); } public void deleteIconFile(GameInstanceID instanceId) { - Path root = getInstanceRoot(instanceId); + Path root = getLayout().getInstanceRoot(instanceId); for (String extension : FXUtils.IMAGE_EXTENSIONS) { Path file = root.resolve("icon." + extension); try { @@ -817,7 +817,7 @@ public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRun @Override public Path getModpackConfiguration(GameInstanceID instanceId) { - return getInstanceRoot(instanceId).resolve("modpack.cfg"); + return getLayout().getInstanceRoot(instanceId).resolve("modpack.cfg"); } public void markInstanceAsModpack(GameInstanceID instanceId) { @@ -830,13 +830,13 @@ public void undoMark(GameInstanceID instanceId) { public void markInstanceLaunchedAbnormally(GameInstanceID instanceId) { try { - Files.createFile(getInstanceRoot(instanceId).resolve(".abnormal")); + Files.createFile(getLayout().getInstanceRoot(instanceId).resolve(".abnormal")); } catch (IOException ignored) { } } public boolean unmarkInstanceLaunchedAbnormally(GameInstanceID instanceId) { - Path file = getInstanceRoot(instanceId).resolve(".abnormal"); + Path file = getLayout().getInstanceRoot(instanceId).resolve(".abnormal"); if (Files.isRegularFile(file)) { try { Files.delete(file); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index ea79242cf17..2d7f4a0c87b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -186,7 +186,9 @@ private void launch0() { Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION); if (lib == null) return null; - Path file = dependencyManager.getGameRepository().getLibraryFile(version.get(), lib); + GameRepository gameRepository = dependencyManager.getGameRepository(); + GameInstanceManifest manifest = version.get(); + Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), lib); if (file.toAbsolutePath().toString().indexOf('=') >= 0) { LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader"); return null; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java index 3b34a007645..89f8e6faee9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java @@ -132,7 +132,7 @@ public static GameSettings.Preset toPreset(GameSettingsPresetID id, int autoName HMCLGameRepository repository, GameInstanceID instanceId, @Nullable GameSettingsPresetID parent) { - Path instanceRoot = repository.getInstanceRoot(instanceId); + Path instanceRoot = repository.getLayout().getInstanceRoot(instanceId); Path file = instanceRoot.resolve(LEGACY_INSTANCE_SETTINGS_FILENAME); if (!Files.exists(file)) { return null; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java index 7685584beaa..b916dfb0f69 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 @@ -1869,7 +1869,7 @@ private String getCurrentInstanceVersionRoot() { return ""; } - return repository.getInstanceRoot(instanceId).toString(); + return repository.getLayout().getInstanceRoot(instanceId).toString(); } /// Keeps a listener attached to the current instance's parent preset property. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java index 4d62e3263b3..3b7b3291eda 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java @@ -28,11 +28,7 @@ import org.jackhuang.hmcl.download.game.GameAssetDownloadTask; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameLibrariesTask; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import org.jackhuang.hmcl.game.LauncherHelper; -import org.jackhuang.hmcl.game.QuickPlayOption; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Schedulers; @@ -121,7 +117,7 @@ public static void downloadModpackImpl(DownloadProvider downloadProvider, HMCLGa public static void deleteInstance(HMCLGameRepository repository, GameInstanceID instanceId) { boolean isIndependent = repository.getRunDirectory(instanceId).toAbsolutePath().normalize() - .equals(repository.getInstanceRoot(instanceId).toAbsolutePath().normalize()); + .equals(repository.getLayout().getInstanceRoot(instanceId).toAbsolutePath().normalize()); String message = isIndependent ? i18n("instance.manage.remove.confirm.independent", instanceId) : i18n("instance.manage.remove.confirm.trash", instanceId, instanceId + "_removed"); diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java index f9a022c0584..58cc71b4e78 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -463,8 +463,8 @@ public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@Tem repository.applyDefaultIsolationSettingForNewInstance(id, true); - assertEquals(repository.getInstanceRoot(id), repository.getRunDirectory(id)); - assertEquals(repository.getInstanceRoot(id).resolve("mods"), repository.getModsDirectory(id)); + assertEquals(repository.getLayout().getInstanceRoot(id), repository.getRunDirectory(id)); + assertEquals(repository.getLayout().getInstanceRoot(id).resolve("mods"), repository.getModsDirectory(id)); assertTrue(repository.removeInstanceFromDisk(id)); assertEquals(repository.getBaseDirectory(), repository.getRunDirectory(id)); @@ -529,7 +529,7 @@ public void legacyInstanceSettingsMigrationStoresLegacyGameDirectoryPresetAsPare settings().defaultGameSettingsPresetProperty().set(defaultPresetId); HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); GameInstanceID instanceId = new GameInstanceID("1.20.1"); - Path versionRoot = repository.getInstanceRoot(instanceId); + Path versionRoot = repository.getLayout().getInstanceRoot(instanceId); Files.createDirectories(versionRoot); Files.writeString(versionRoot.resolve("hmclversion.cfg"), """ { @@ -571,7 +571,7 @@ public void startupMigrationSkipsLegacyInstanceSettingsFile(@TempDir Path tempDi HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); writeVersionJson(repository, "1.20.1"); GameInstanceID instanceId = new GameInstanceID("1.20.1"); - Path versionRoot = repository.getInstanceRoot(instanceId); + Path versionRoot = repository.getLayout().getInstanceRoot(instanceId); Files.writeString(versionRoot.resolve(LegacyGameSettingsMigrator.LEGACY_INSTANCE_SETTINGS_FILENAME), """ { "usesGlobal": true @@ -839,7 +839,8 @@ public void close() throws ReflectiveOperationException { /// Writes a minimal valid version json for repository refresh tests. private static void writeVersionJson(HMCLGameRepository repository, String id) throws IOException { - Path versionRoot = repository.getInstanceRoot(new GameInstanceID(id)); + GameInstanceID instanceId = new GameInstanceID(id); + Path versionRoot = repository.getLayout().getInstanceRoot(instanceId); Files.createDirectories(versionRoot); Files.writeString(versionRoot.resolve(id + ".json"), """ { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java index 3373165ff9f..060c806219b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -24,10 +24,7 @@ import org.jackhuang.hmcl.download.game.GameLibrariesTask; import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask; import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; @@ -136,7 +133,7 @@ public Task checkPatchCompletionAsync(GameInstanceManifest manifest, boolean if (GameLibrariesTask.shouldDownloadLibrary(repository, manifest, installer, integrityCheck)) { tasks.add(installLibraryAsync(gameVersion, original, "optifine", optifinePatchVersion)); } else { - tasks.add(OptiFineInstallTask.install(this, original, repository.getLibraryFile(manifest, installer))); + tasks.add(OptiFineInstallTask.install(this, original, repository.getLayout().getLibraryFile(manifest.id(), installer))); } } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java index 770137a8df0..8c8986e9dca 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java @@ -160,7 +160,7 @@ private static GameInstanceManifest maintainGameWithCpwModLauncher(GameRepositor optiFine.ifPresent(library -> { builder.addJvmArgument("-Dhmcl.transformer.candidates=${library_directory}/" + library.getPath()); if (!libraryExisting) builder.addLibrary(hmclTransformerDiscoveryService); - Path libraryPath = repository.getLibraryFile(manifest, hmclTransformerDiscoveryService); + Path libraryPath = repository.getLayout().getLibraryFile(manifest.id(), hmclTransformerDiscoveryService); try (InputStream input = MaintainTask.class.getResourceAsStream("/assets/game/HMCLTransformerDiscoveryService-1.0.jar")) { Files.createDirectories(libraryPath.getParent()); Files.copy(Objects.requireNonNull(input, "Bundled HMCLTransformerDiscoveryService is missing."), libraryPath, StandardCopyOption.REPLACE_EXISTING); @@ -181,7 +181,7 @@ private static String updateIgnoreList(GameRepository repository, GameInstanceMa // we need to manually ignore ${primary_jar}. newIgnoreList.add("${primary_jar}"); - Path libraryDirectory = repository.getLibrariesDirectory(manifest).toAbsolutePath().normalize(); + Path libraryDirectory = repository.getLayout().getLibrariesDirectory().toAbsolutePath().normalize(); // The default ignoreList is too loose and may cause some problems, we replace them with the absolute version. // For example, if "client-extra" is in ignoreList, and game directory contains "client-extra" component, all @@ -260,7 +260,7 @@ private static GameInstanceManifest maintainOptiFineLibrary(GameRepository repos Library library = libraries.get(i); if (library.is("optifine", "OptiFine")) { Library newLibrary = new Library(new Artifact("optifine", "OptiFine", library.version(), "installer")); - if (Files.exists(repository.getLibraryFile(manifest, newLibrary))) { + if (Files.exists(repository.getLayout().getLibraryFile(manifest.id(), newLibrary))) { libraries.set(i, null); // OptiFine should be loaded after Forge in classpath. // Although we have altered priority of OptiFine higher than Forge, diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java index 50b18ac8a33..4b89a0d55ed 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java @@ -23,13 +23,7 @@ import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile.Processor; import org.jackhuang.hmcl.download.game.GameLibrariesTask; import org.jackhuang.hmcl.download.game.GameInstanceJsonDownloadTask; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.DownloadInfo; -import org.jackhuang.hmcl.game.DownloadType; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.DigestUtils; @@ -302,7 +296,7 @@ public void preExecute() throws Exception { for (Library library : profile.getLibraries()) { Path file = fs.getPath("maven").resolve(library.getPath()); if (Files.exists(file)) { - Path dest = gameRepository.getLibraryFile(manifest, library); + Path dest = gameRepository.getLayout().getLibraryFile(manifest.id(), library); FileUtils.copyFile(file, dest); } } @@ -413,7 +407,7 @@ public void execute() throws Exception { vars.put("MINECRAFT_VERSION", FileUtils.getAbsolutePath(gameRepository.getInstanceJar(manifest))); vars.put("ROOT", FileUtils.getAbsolutePath(gameRepository.getBaseDirectory())); vars.put("INSTALLER", installer.toAbsolutePath().toString()); - vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLibrariesDirectory(manifest))); + vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLayout().getLibrariesDirectory())); updateProgress(0, processors.size()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java index 1e2430dcc82..06e18942c8f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java @@ -22,6 +22,7 @@ import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; +import org.jackhuang.hmcl.game.GameRepository; import org.jackhuang.hmcl.game.Library; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -73,7 +74,8 @@ public void execute() throws Exception { // unpack the universal jar in the installer file. Library forgeLibrary = new Library(installProfile.getInstall().getPath()); - Path forgeFile = dependencyManager.getGameRepository().getLibraryFile(manifest, forgeLibrary); + GameRepository gameRepository = dependencyManager.getGameRepository(); + Path forgeFile = gameRepository.getLayout().getLibraryFile(manifest.id(), forgeLibrary); Files.createDirectories(forgeFile.getParent()); ZipEntry forgeEntry = zipFile.getEntry(installProfile.getInstall().getFilePath()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java index 8d87159a23c..d0e72cc3397 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java @@ -59,7 +59,9 @@ public GameAssetDownloadTask(AbstractDependencyManager dependencyManager, GameIn this.dependencyManager = dependencyManager; this.manifest = manifest.resolve(dependencyManager.getGameRepository()); this.assetIndexInfo = this.manifest.getAssetIndex(); - this.assetIndexFile = dependencyManager.getGameRepository().getIndexFile(manifest.id(), assetIndexInfo.getId()); + GameRepository gameRepository = dependencyManager.getGameRepository(); + String assetId = assetIndexInfo.getId(); + this.assetIndexFile = gameRepository.getLayout().getAssetIndexFile(assetId); this.integrityCheck = integrityCheck; setStage("hmcl.install.assets"); @@ -90,7 +92,8 @@ public void execute() throws Exception { if (isCancelled()) throw new InterruptedException(); - Path file = dependencyManager.getGameRepository().getAssetObject(manifest.id(), assetIndexInfo.getId(), assetObject); + GameRepository gameRepository = dependencyManager.getGameRepository(); + Path file = gameRepository.getLayout().getAssetObject(assetObject); boolean download = !Files.isRegularFile(file); try { if (!download && integrityCheck && !assetObject.validateChecksum(file, true)) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetIndexDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetIndexDownloadTask.java index 110e99b72e2..a6d937c9e62 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetIndexDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetIndexDownloadTask.java @@ -19,10 +19,7 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.AbstractDependencyManager; -import org.jackhuang.hmcl.game.AssetIndex; -import org.jackhuang.hmcl.game.AssetIndexInfo; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.DigestUtils; @@ -70,7 +67,9 @@ public List> getDependencies() { @Override public void execute() { AssetIndexInfo assetIndexInfo = manifest.getAssetIndex(); - Path assetIndexFile = dependencyManager.getGameRepository().getIndexFile(manifest.id(), assetIndexInfo.getId()); + GameRepository gameRepository = dependencyManager.getGameRepository(); + String assetId = assetIndexInfo.getId(); + Path assetIndexFile = gameRepository.getLayout().getAssetIndexFile(assetId); boolean verifyHashCode = StringUtils.isNotBlank(assetIndexInfo.getSha1()) && assetIndexInfo.getUrl().contains(assetIndexInfo.getSha1()); if (Files.exists(assetIndexFile) && !forceDownloading) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java index b268f2831d3..3e98f121031 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 @@ -92,7 +92,7 @@ public List> getDependencies() { } public static boolean shouldDownloadLibrary(GameRepository gameRepository, GameInstanceManifest manifest, Library library, boolean integrityCheck) { - Path file = gameRepository.getLibraryFile(manifest, library); + Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), library); if (!Files.isRegularFile(file)) return true; if (!integrityCheck) { @@ -165,7 +165,7 @@ public void execute() throws IOException { } } - Path file = gameRepository.getLibraryFile(manifest, library); + Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), library); if ("optifine".equals(library.groupId()) && Files.exists(file) && GameVersionNumber.asGameVersion(gameRepository.getGameVersion(manifest).orElse(null)).compareTo("1.20.4") == 0) { String forgeVersion = LibraryAnalyzer.analyze(manifest, "1.20.4") .getVersion(LibraryAnalyzer.LibraryType.FORGE) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java index 3919125371a..4280937a526 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java @@ -280,7 +280,7 @@ public void preExecute() throws Exception { for (Library library : profile.getLibraries()) { Path file = fs.getPath("maven").resolve(library.getPath()); if (Files.exists(file)) { - Path dest = gameRepository.getLibraryFile(manifest, library); + Path dest = gameRepository.getLayout().getLibraryFile(manifest.id(), library); FileUtils.copyFile(file, dest); } } @@ -391,7 +391,7 @@ public void execute() throws Exception { vars.put("MINECRAFT_VERSION", FileUtils.getAbsolutePath(gameRepository.getInstanceJar(manifest))); vars.put("ROOT", FileUtils.getAbsolutePath(gameRepository.getBaseDirectory())); vars.put("INSTALLER", installer.toAbsolutePath().toString()); - vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLibrariesDirectory(manifest))); + vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLayout().getLibrariesDirectory())); updateProgress(0, processors.size()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java index 8e39854b17c..4918eb5ac7d 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 @@ -130,7 +130,7 @@ public void execute() throws Exception { List libraries = new ArrayList<>(4); libraries.add(optiFineLibrary); - Path optiFineInstallerLibraryPath = gameRepository.getLibraryFile(manifest, optiFineInstallerLibrary); + Path optiFineInstallerLibraryPath = gameRepository.getLayout().getLibraryFile(manifest.id(), optiFineInstallerLibrary); FileUtils.copyFile(dest, optiFineInstallerLibraryPath); try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(optiFineInstallerLibraryPath)) { @@ -140,7 +140,7 @@ public void execute() throws Exception { // Install launch wrapper modified by OptiFine boolean hasLaunchWrapper = false; try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(dest)) { - Path optiFineLibraryPath = gameRepository.getLibraryFile(manifest, optiFineLibrary); + Path optiFineLibraryPath = gameRepository.getLayout().getLibraryFile(manifest.id(), optiFineLibrary); if (Files.exists(fs.getPath("optifine/Patcher.class"))) { String[] command = { JavaRuntime.getDefault().getBinary().toString(), @@ -165,7 +165,7 @@ public void execute() throws Exception { Path launchWrapper2 = fs.getPath("launchwrapper-2.0.jar"); if (Files.exists(launchWrapper2)) { Library launchWrapper = new Library(new Artifact("optifine", "launchwrapper", "2.0")); - Path launchWrapperFile = gameRepository.getLibraryFile(manifest, launchWrapper); + Path launchWrapperFile = gameRepository.getLayout().getLibraryFile(manifest.id(), launchWrapper); Files.createDirectories(launchWrapperFile.toAbsolutePath().getParent()); FileUtils.copyFile(launchWrapper2, launchWrapperFile); hasLaunchWrapper = true; @@ -180,7 +180,7 @@ public void execute() throws Exception { Library launchWrapper = new Library(new Artifact("optifine", "launchwrapper-of", launchWrapperVersion)); if (Files.exists(launchWrapperJar)) { - Path launchWrapperFile = gameRepository.getLibraryFile(manifest, launchWrapper); + Path launchWrapperFile = gameRepository.getLayout().getLibraryFile(manifest.id(), launchWrapper); Files.createDirectories(launchWrapperFile.toAbsolutePath().getParent()); FileUtils.copyFile(launchWrapperJar, launchWrapperFile); 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 22e3f5d9782..e26ebd528ea 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -378,7 +378,7 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { Status currentStatus = status; currentStatus.instances.remove(id); - Path file = getInstanceRoot(id); + Path file = getLayout().getInstanceRoot(id); if (Files.notExists(file)) { return true; } @@ -436,7 +436,7 @@ public Optional getGameVersion(GameInstanceManifest manifest) { @Override public Path getNativeDirectory(GameInstanceID instanceId, Platform platform) { - return getInstanceRoot(instanceId).resolve("natives-" + platform); + return getLayout().getInstanceRoot(instanceId).resolve("natives-" + platform); } @Override @@ -456,7 +456,7 @@ public Path getInstanceJson(GameInstanceID instanceId) { @Override public AssetIndex getAssetIndex(GameInstanceID instanceId, String assetId) throws IOException { try { - return Objects.requireNonNull(JsonUtils.fromJsonFile(getIndexFile(instanceId, assetId), AssetIndex.class)); + return Objects.requireNonNull(JsonUtils.fromJsonFile(getLayout().getAssetIndexFile(assetId), AssetIndex.class)); } catch (JsonParseException | NullPointerException e) { throw new IOException("Asset index file malformed", e); } @@ -468,7 +468,7 @@ public Path getActualAssetDirectory(GameInstanceID instanceId, String assetId) { return reconstructAssets(instanceId, assetId); } catch (IOException | JsonParseException e) { LOG.error("Unable to reconstruct asset directory", e); - return getAssetDirectory(instanceId, assetId); + return getLayout().getAssetDirectory(); } } @@ -477,7 +477,7 @@ public Optional getAssetObject(GameInstanceID instanceId, String assetId, try { AssetObject assetObject = getAssetIndex(instanceId, assetId).getObjects().get(name); if (assetObject == null) return Optional.empty(); - return Optional.of(getAssetObject(instanceId, assetId, assetObject)); + return Optional.of(getLayout().getAssetObject(assetObject)); } catch (IOException e) { throw e; } catch (Exception e) { @@ -490,8 +490,8 @@ public Path getAssetObject(GameInstanceID instanceId, Path assetDir, AssetObject } protected Path reconstructAssets(GameInstanceID instanceId, String assetId) throws IOException, JsonParseException { - Path assetsDir = getAssetDirectory(instanceId, assetId); - Path indexFile = getIndexFile(instanceId, assetId); + Path assetsDir = getLayout().getAssetDirectory(); + Path indexFile = getLayout().getAssetIndexFile(assetId); Path virtualRoot = assetsDir.resolve("virtual").resolve(assetId); if (!Files.isRegularFile(indexFile)) @@ -551,7 +551,7 @@ public Task saveAsync(GameInstanceManifest instanceManifes } public Path getModpackConfiguration(GameInstanceID instanceId) { - return getInstanceRoot(instanceId).resolve("modpack.json"); + return getLayout().getInstanceRoot(instanceId).resolve("modpack.json"); } @Nullable 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 39b16d553d3..23cdb130984 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -83,45 +83,12 @@ default Task refreshAsync() { return Task.runAsync(this::refresh); } - /// Returns the directory that stores files belonging to an instance. - /// - /// Delegates to [GameRepositoryLayout#getInstanceRoot(GameInstanceID)]. - /// - /// @param instanceId the instance id - /// @return the instance root directory - default Path getInstanceRoot(GameInstanceID instanceId) { - return getLayout().getInstanceRoot(instanceId); - } - /// Returns the working directory used when launching an instance. /// /// @param instanceId the instance id /// @return the run directory Path getRunDirectory(GameInstanceID instanceId); - /// Returns the base directory used to store shared libraries for a manifest. - /// - /// Delegates to [GameRepositoryLayout#getLibrariesDirectory()]. The manifest argument is - /// retained for API compatibility and is not used by the default implementation. - /// - /// @param manifest the manifest whose libraries are being resolved - /// @return the libraries directory - default Path getLibrariesDirectory(GameInstanceManifest manifest) { - return getLayout().getLibrariesDirectory(); - } - - /// Returns the expected filesystem path for a library. - /// - /// Delegates to [GameRepositoryLayout#getLibraryFile(GameInstanceID, Library)] using - /// [GameInstanceManifest#id()] as the library owner. - /// - /// @param manifest the manifest that owns or references the library - /// @param lib the library descriptor - /// @return the library file path - default Path getLibraryFile(GameInstanceManifest manifest, Library lib) { - return getLayout().getLibraryFile(manifest.id(), lib); - } - /// Returns the directory used for extracted native libraries of an instance and platform. /// /// @param instanceId the instance id @@ -185,18 +152,6 @@ default Path getInstanceJar(GameInstanceID instanceId) throws NoSuchGameInstance /// @return the actual asset directory Path getActualAssetDirectory(GameInstanceID instanceId, String assetId); - /// Returns the base asset storage directory for an instance. - /// - /// Delegates to [GameRepositoryLayout#getAssetDirectory()]. The instance and asset id - /// arguments are retained for API compatibility and are not used by the default implementation. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @return the asset storage directory - default Path getAssetDirectory(GameInstanceID instanceId, String assetId) { - return getLayout().getAssetDirectory(); - } - /// Returns an existing asset object path by logical asset name. /// /// @param instanceId the instance id @@ -206,19 +161,6 @@ default Path getAssetDirectory(GameInstanceID instanceId, String assetId) { /// @throws IOException if the asset index cannot be read Optional getAssetObject(GameInstanceID instanceId, String assetId, String name) throws IOException; - /// Returns the expected path for an asset object descriptor. - /// - /// Delegates to [GameRepositoryLayout#getAssetObject(AssetObject)]. The instance and asset id - /// arguments are retained for API compatibility and are not used by the default implementation. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @param obj the asset object descriptor - /// @return the asset object path - default Path getAssetObject(GameInstanceID instanceId, String assetId, AssetObject obj) { - return getLayout().getAssetObject(obj); - } - /// Reads an asset index. /// /// @param instanceId the instance id @@ -227,31 +169,6 @@ default Path getAssetObject(GameInstanceID instanceId, String assetId, AssetObje /// @throws IOException if the asset index cannot be read AssetIndex getAssetIndex(GameInstanceID instanceId, String assetId) throws IOException; - /// Returns the path of an asset index file. - /// - /// Delegates to [GameRepositoryLayout#getAssetIndexFile(String)]. The instance id is retained - /// for API compatibility and is not used by the default implementation. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @return the asset index file path - default Path getIndexFile(GameInstanceID instanceId, String assetId) { - return getLayout().getAssetIndexFile(assetId); - } - - /// Returns the path of a logging configuration object. - /// - /// Delegates to [GameRepositoryLayout#getLoggingObject(String, LoggingInfo)]. The instance id is - /// retained for API compatibility and is not used by the default implementation. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id used as the logging object namespace - /// @param loggingInfo the logging configuration descriptor - /// @return the logging object path - default Path getLoggingObject(GameInstanceID instanceId, String assetId, LoggingInfo loggingInfo) { - return getLayout().getLoggingObject(assetId, loggingInfo); - } - /// Returns the classpath entries whose library files are present on disk. /// /// @param manifest the manifest whose libraries should be mapped to classpath entries @@ -261,7 +178,7 @@ default Set getClasspath(GameInstanceManifest manifest) { if (manifest.libraries() != null) { for (Library library : manifest.libraries()) if (library.appliesToCurrentEnvironment() && !library.isNative()) { - Path f = getLibraryFile(manifest, library); + Path f = getLayout().getLibraryFile(manifest.id(), library); if (Files.isRegularFile(f)) classpath.add(FileUtils.getAbsolutePath(f)); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 4ef8e3af8bd..f6e1c86a591 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -463,7 +463,7 @@ public void decompressNatives(Path destination) throws NotDecompressingNativesEx FileUtils.cleanDirectoryQuietly(destination); for (Library library : manifest.getLibraries()) if (library.isNative()) - new Unzipper(repository.getLibraryFile(manifest, library), destination) + new Unzipper(repository.getLayout().getLibraryFile(manifest.id(), library), destination) .setFilter((zipEntry, destFile, relativePath) -> { if (!zipEntry.isDirectory() && !zipEntry.isUnixSymlink() && Files.isRegularFile(destFile) @@ -494,7 +494,7 @@ private boolean isUsingLog4j() { } public Path getLog4jConfigurationFile() { - return repository.getInstanceRoot(manifest.id()).resolve("log4j2.xml"); + return repository.getLayout().getInstanceRoot(manifest.id()).resolve("log4j2.xml"); } public void extractLog4jConfigurationFile() throws IOException { @@ -537,7 +537,7 @@ protected Map getConfigurations() { pair("${user_properties}", authInfo.getUserProperties()), pair("${resolution_width}", options.getWidth().toString()), pair("${resolution_height}", options.getHeight().toString()), - pair("${library_directory}", FileUtils.getAbsolutePath(repository.getLibrariesDirectory(manifest))), + pair("${library_directory}", FileUtils.getAbsolutePath(repository.getLayout().getLibrariesDirectory())), pair("${classpath_separator}", File.pathSeparator), pair("${primary_jar}", FileUtils.getAbsolutePath(repository.getInstanceJar(manifest))), pair("${language}", Locale.getDefault().toLanguageTag()), @@ -546,7 +546,7 @@ protected Map getConfigurations() { // libraries_directory stands for historical reasons here. We don't know the official launcher // had already defined "library_directory" as the placeholder for path to ".minecraft/libraries" // when we propose this placeholder. - pair("${libraries_directory}", FileUtils.getAbsolutePath(repository.getLibrariesDirectory(manifest))), + pair("${libraries_directory}", FileUtils.getAbsolutePath(repository.getLayout().getLibrariesDirectory())), // file_separator is used in -DignoreList pair("${file_separator}", File.separator), pair("${primary_jar_name}", FileUtils.getName(repository.getInstanceJar(manifest))) @@ -622,7 +622,7 @@ private Map getEnvVars(Path nativeFolder) { Map env = new LinkedHashMap<>(); env.put("INST_NAME", versionName); env.put("INST_ID", versionName); - env.put("INST_DIR", FileUtils.getAbsolutePath(repository.getInstanceRoot(manifest.id()))); + env.put("INST_DIR", FileUtils.getAbsolutePath(repository.getLayout().getInstanceRoot(manifest.id()))); env.put("INST_MC_DIR", FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id()))); env.put("INST_JAVA", options.getJava().getBinary().toString()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java index f06f08a7267..f266348cfc0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java @@ -56,7 +56,7 @@ public Collection> getDependencies() { @Override public void execute() throws Exception { - FileUtils.copyDirectory(repository.getInstanceRoot(id), backupFolder); + FileUtils.copyDirectory(repository.getLayout().getInstanceRoot(id), backupFolder); } @Override @@ -72,7 +72,7 @@ public void postExecute() throws Exception { // Restore backup repository.removeInstanceFromDisk(id); - FileUtils.copyDirectory(backupFolder, repository.getInstanceRoot(id)); + FileUtils.copyDirectory(backupFolder, repository.getLayout().getInstanceRoot(id)); repository.refreshAsync().start(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java index d48570f82a4..3dab545053c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java @@ -88,7 +88,7 @@ public CurseCompletionTask(DefaultDependencyManager dependencyManager, GameInsta if (manifest == null) try { - Path manifestFile = repository.getInstanceRoot(instanceId).resolve("manifest.json"); + Path manifestFile = repository.getLayout().getInstanceRoot(instanceId).resolve("manifest.json"); if (Files.exists(manifestFile)) this.manifest = JsonUtils.fromJsonFile(manifestFile, CurseManifest.class); } catch (Exception e) { @@ -113,7 +113,7 @@ public void execute() throws Exception { if (manifest == null) return; - Path root = repository.getInstanceRoot(instanceId); + Path root = repository.getLayout().getInstanceRoot(instanceId); // Because in China, Curse is too difficult to visit, // if failed, ignore it and retry next time. @@ -141,7 +141,8 @@ public void execute() throws Exception { .collect(Collectors.toList())); JsonUtils.writeToJsonFile(root.resolve("manifest.json"), newManifest); - Path versionRoot = repository.getInstanceRoot(modManager.getInstanceId()); + GameInstanceID instanceId1 = modManager.getInstanceId(); + Path versionRoot = repository.getLayout().getInstanceRoot(instanceId1); Path resourcePacksRoot = versionRoot.resolve("resourcepacks"); Path shaderPacksRoot = versionRoot.resolve("shaderpacks"); finished.set(0); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java index f19dd5ec932..484a369a1fa 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 @@ -173,7 +173,7 @@ public void execute() throws Exception { // CurseForge manifest where fileName is missing. CurseCompletionTask // resolves those file names and writes the enriched manifest to // manifest.json, so read from there when available. - Path oldManifestFile = repository.getInstanceRoot(instanceId).resolve("manifest.json"); + Path oldManifestFile = repository.getLayout().getInstanceRoot(instanceId).resolve("manifest.json"); List oldFiles = config.getManifest().files(); if (Files.exists(oldManifestFile)) { try { @@ -197,7 +197,7 @@ public void execute() throws Exception { } } - Path root = repository.getInstanceRoot(instanceId); + Path root = repository.getLayout().getInstanceRoot(instanceId); Files.createDirectories(root); JsonUtils.writeToJsonFile(root.resolve("manifest.json"), manifest); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java index b97bf82bc83..78906b4de16 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java @@ -110,7 +110,7 @@ public CompletableFuture getFuture(TaskCompletableFuture executor) { throw new IOException("Unable to parse server manifest.json from " + manifest.getFileApi(), e); } - Path rootPath = repository.getInstanceRoot(instanceId); + Path rootPath = repository.getLayout().getInstanceRoot(instanceId); Files.createDirectories(rootPath); Map localFiles = manifest.getFiles().stream().collect(Collectors.toMap(Function.identity(), Function.identity())); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java index d44673ac8be..4dc5022c64c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java @@ -78,7 +78,7 @@ public ModrinthCompletionTask(DefaultDependencyManager dependencyManager, GameIn if (manifest == null) try { - Path manifestFile = repository.getInstanceRoot(instanceId).resolve("modrinth.index.json"); + Path manifestFile = repository.getLayout().getInstanceRoot(instanceId).resolve("modrinth.index.json"); if (Files.exists(manifestFile)) this.manifest = JsonUtils.fromJsonFile(manifestFile, ModrinthManifest.class); } catch (Exception e) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java index e378fa4811c..aab7a246166 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 @@ -153,7 +153,7 @@ public void execute() throws Exception { } } - Path root = repository.getInstanceRoot(instanceId); + Path root = repository.getLayout().getInstanceRoot(instanceId); Files.createDirectories(root); JsonUtils.writeToJsonFile(root.resolve("modrinth.index.json"), manifest); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java index da24c80ecc6..0625665f018 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 @@ -249,7 +249,7 @@ public void execute() throws Exception { Path libraries = root.resolve("libraries"); if (Files.exists(libraries)) - FileUtils.copyDirectory(libraries, repository.getInstanceRoot(instanceId).resolve("libraries")); + FileUtils.copyDirectory(libraries, repository.getLayout().getInstanceRoot(instanceId).resolve("libraries")); for (Library library : artifact.getManifest().getLibraries()) { if ("local".equals(library.hint())) { @@ -257,15 +257,15 @@ public void execute() throws Exception { Retain them will facilitate compatibility, as some embedded libraries may check where their JAR is. Meanwhile, potential compatibility issue with other launcher which never supports these fields might occur. Here, we make the file stored twice, to keep maximum compatibility. */ - Path from = repository.getLibraryFile(artifact.getManifest(), library); - Path target = repository.getLibraryFile(artifact.getManifest(), library.withoutCommunityFields()); + Path from = repository.getLayout().getLibraryFile(artifact.getManifest().id(), library); + Path target = repository.getLayout().getLibraryFile(artifact.getManifest().id(), library.withoutCommunityFields()); Files.createDirectories(target.getParent()); Files.copy(from, target, StandardCopyOption.REPLACE_EXISTING); } } try (InputStream input = MaintainTask.class.getResourceAsStream("/assets/game/HMCLMultiMCBootstrap-1.0.jar")) { - Path libraryPath = repository.getLibraryFile(artifact.getManifest(), MultiMCInstancePatch.BOOTSTRAP_LIBRARY); + Path libraryPath = repository.getLayout().getLibraryFile(artifact.getManifest().id(), MultiMCInstancePatch.BOOTSTRAP_LIBRARY); Files.createDirectories(libraryPath.getParent()); Files.copy(Objects.requireNonNull(input, "Bundled HMCLMultiMCBootstrap is missing."), libraryPath, StandardCopyOption.REPLACE_EXISTING); @@ -275,7 +275,7 @@ public void execute() throws Exception { if (iconKey != null) { Path iconFile = root.resolve(iconKey + ".png"); if (Files.exists(iconFile)) { - FileUtils.copyFile(iconFile, repository.getInstanceRoot(instanceId).resolve("icon.png")); + FileUtils.copyFile(iconFile, repository.getLayout().getInstanceRoot(instanceId).resolve("icon.png")); } } } @@ -335,7 +335,7 @@ public void postExecute() throws Exception { Path root = getRootPath(fs).resolve("jarmods"); try (FileSystem mc = CompressingUtils.writable( - repository.getInstanceRoot(instanceId).resolve(instanceId + ".jar") + repository.getLayout().getInstanceRoot(instanceId).resolve(instanceId + ".jar") ).setAutoDetectEncoding(true).build()) { for (String fileName : files) { try (FileSystem jm = CompressingUtils.readonly(root.resolve(fileName)).setAutoDetectEncoding(true).build()) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java index 2caca9ab70e..f895c7b6ba3 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 @@ -121,7 +121,7 @@ public void execute() throws Exception { dependencies.add(builder.buildAsync()); } - Path rootPath = repository.getInstanceRoot(instanceId).toAbsolutePath().normalize(); + Path rootPath = repository.getLayout().getInstanceRoot(instanceId).toAbsolutePath().normalize(); Map files = manifest.getManifest().getFiles().stream() .collect(Collectors.toMap(ModpackConfiguration.FileInformation::getPath, Function.identity())); From 365708d71577829572b0a8f850bae74d246bd46f Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 3 Aug 2026 19:53:07 +0800 Subject: [PATCH 004/199] feat: add DefaultGameInstance and GameInstance interface for game instance management --- .../hmcl/game/DefaultGameInstance.java | 82 +++ .../hmcl/game/DefaultGameRepository2.java | 679 ++++++++++++++++++ .../org/jackhuang/hmcl/game/GameInstance.java | 114 +++ 3 files changed, 875 insertions(+) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository2.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java new file mode 100644 index 00000000000..64c7a81d8f4 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -0,0 +1,82 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Path; + +@NotNullByDefault +public class DefaultGameInstance implements GameInstance { + + private final DefaultGameRepository repository; + private final DefaultGameRepositoryLayout layout; + private final GameInstanceID id; + private final GameInstanceManifest manifest; + private GameInstanceManifest.@Nullable Resolved resolvedManifest; + + protected DefaultGameInstance( + DefaultGameRepository.Status status, + DefaultGameRepository repository, DefaultGameRepositoryLayout layout, + GameInstanceID id, GameInstanceManifest manifest) { + this.repository = repository; + this.layout = layout; + this.id = id; + this.manifest = manifest; + } + + @Override + public GameRepository getRepository() { + return repository; + } + + @Override + public GameInstanceID getId() { + return id; + } + + @Override + public GameInstanceManifest getManifest() { + return manifest; + } + + @Override + public GameInstanceManifest.Resolved getResolvedManifest() { + if (resolvedManifest == null) { + resolvedManifest = repository.resolve(manifest); // TODO + } + + return resolvedManifest; + } + + @Override + public Path getInstanceRoot() { + return layout.getInstanceRoot(id); + } + + @Override + public Path getInstanceJarFile() { + return layout.getInstanceJarFile(id); + } + + @Override + public Path getRunDirectory() { + return layout.getBaseDirectory(); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository2.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository2.java new file mode 100644 index 00000000000..7a6a9822582 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository2.java @@ -0,0 +1,679 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.addon.mod.ModManager; +import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; +import org.jackhuang.hmcl.download.MaintainTask; +import org.jackhuang.hmcl.event.*; +import org.jackhuang.hmcl.modpack.ModpackConfiguration; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.platform.Platform; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.InvalidPathException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Stream; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +@NotNullByDefault +public class DefaultGameRepository2 implements GameRepository { + + private static final GameInstanceManifest CLASSIC_MANIFEST = new GameInstanceManifest( + new GameInstanceID("Classic"), + "${auth_player_name} ${auth_session} --workDir ${game_directory}", + null, + "net.minecraft.client.Minecraft", + null, + null, + null, + null, + null, + null, + List.of( + classicLibrary("lwjgl"), + classicLibrary("jinput"), + classicLibrary("lwjgl_util")), + null, + null, + null, + ReleaseType.UNKNOWN, + null, + null, + 0, + false, + false, + null, + null + ); + + private static Library classicLibrary(String name) { + return new Library(new Artifact("", "", ""), null, + new LibrariesDownloadInfo(new LibraryDownloadInfo("bin/" + name + ".jar"), null), + null, null, null, null, null, null); + } + + private static boolean hasClassicVersion(Path baseDirectory) { + Path bin = baseDirectory.resolve("bin"); + return Files.isDirectory(bin) + && Files.exists(bin.resolve("lwjgl.jar")) + && Files.exists(bin.resolve("jinput.jar")) + && Files.exists(bin.resolve("lwjgl_util.jar")); + } + + private volatile Status status; + private volatile boolean loaded; + private final ConcurrentHashMap> gameVersions = new ConcurrentHashMap<>(); + + public DefaultGameRepository2(Path baseDirectory) { + this.status = new Status(new DefaultGameRepositoryLayout(baseDirectory)); + } + + public Path getBaseDirectory() { + return status.layout.getBaseDirectory(); + } + + public void setBaseDirectory(Path baseDirectory) { + this.status = new Status(new DefaultGameRepositoryLayout(baseDirectory)); + this.loaded = false; + this.gameVersions.clear(); + } + + @Override + public GameRepositoryLayout getLayout() { + return status.layout; + } + + public boolean isLoaded() { + return loaded; + } + + @Override + public void refresh() { + if (EventBus.EVENT_BUS.fireEvent(new RefreshingInstancesEvent(this)) == Event.Result.DENY) { + return; + } + + refreshImpl(); + loaded = true; + EventBus.EVENT_BUS.fireEvent(new RefreshedGameInstancesEvent(this)); + } + + protected void refreshImpl() { + Status newStatus = new Status(status.layout); + + if (hasClassicVersion(newStatus.layout.getBaseDirectory())) { + GameInstanceID id = CLASSIC_MANIFEST.id(); + newStatus.instances.put(id, new InstanceHolder(newStatus, id, CLASSIC_MANIFEST)); + } + + Path versionsDir = newStatus.layout.getBaseDirectory().resolve("versions"); + if (Files.isDirectory(versionsDir)) { + try (Stream stream = Files.list(versionsDir)) { + stream.parallel().filter(Files::isDirectory).flatMap(dir -> { + GameInstanceID id; + try { + id = new GameInstanceID(FileUtils.getName(dir)); + } catch (IllegalArgumentException e) { + LOG.warning("Ignoring version folder with invalid id " + dir, e); + return Stream.empty(); + } + + Path json = dir.resolve(id + ".json"); + + if (Files.notExists(json)) { + List jsons = FileUtils.listFilesByExtension(dir, "json"); + if (jsons.size() == 1) { + LOG.info("Renaming json file " + jsons.get(0) + " to " + json); + + try { + Files.move(jsons.get(0), json); + } catch (IOException e) { + LOG.warning("Cannot rename json file, ignoring version " + id, e); + return Stream.empty(); + } + + Path jar = dir.resolve(FileUtils.getNameWithoutExtension(jsons.get(0)) + ".jar"); + if (Files.exists(jar)) { + try { + Files.move(jar, dir.resolve(id + ".jar")); + } catch (IOException e) { + LOG.warning("Cannot rename jar file, ignoring version " + id, e); + return Stream.empty(); + } + } + } else { + LOG.info("No available json file found, ignoring version " + id); + return Stream.empty(); + } + } + + GameInstanceManifest manifest; + try { + manifest = readInstanceManifest(json); + } catch (Exception e) { + LOG.warning("Malformed version json " + id, e); + if (EventBus.EVENT_BUS.fireEvent(new GameJsonParseFailedEvent(this, json, id.id())) != Event.Result.ALLOW) { + return Stream.empty(); + } + + try { + manifest = readInstanceManifest(json); + } catch (Exception e2) { + LOG.error("User corrected version json is still malformed", e2); + return Stream.empty(); + } + } + + if (!id.equals(manifest.id())) { + try { + moveInstanceFiles(newStatus.layout.getBaseDirectory(), id, manifest.id()); + } catch (IOException e) { + LOG.warning("Ignoring instance " + manifest.id() + + " because instance id does not match folder name " + id + + ", and we cannot correct it.", e); + return Stream.empty(); + } + } + + return Stream.of(manifest); + }).forEachOrdered(it -> newStatus.instances.put( + it.id(), + new InstanceHolder(newStatus, it.id(), it))); + } catch (IOException e) { + LOG.warning("Failed to load versions from " + versionsDir, e); + } + } + + Map loadedInstances = new TreeMap<>(); + for (InstanceHolder holder : newStatus.instances.values()) { + try { + GameInstanceManifest resolved = newStatus.resolve(holder.manifest, new HashSet<>()).launchManifest(); + if (CompatibilityRule.appliesToCurrentEnvironment(resolved.compatibilityRules())) { + loadedInstances.put(holder.id, holder); + } + } catch (NoSuchGameInstanceException e) { + LOG.warning("Ignoring version " + holder.id + " because it inherits from a nonexistent version."); + } + } + + newStatus.instances.clear(); + newStatus.instances.putAll(loadedInstances); + gameVersions.clear(); + this.status = newStatus; + } + + private static GameInstanceManifest readInstanceManifest(Path json) throws IOException, JsonParseException { + GameInstanceManifest manifest = JsonUtils.fromJsonFile(json, GameInstanceManifest.class); + if (manifest == null) { + throw new JsonParseException("Manifest is null"); + } + return manifest; + } + + private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, GameInstanceID to) throws IOException { + Path versionsDir = baseDirectory.resolve("versions"); + Path fromDir = versionsDir.resolve(from.id()); + Path toDir = versionsDir.resolve(to.id()); + Files.move(fromDir, toDir); + + Path fromJson = toDir.resolve(from + ".json"); + Path fromJar = toDir.resolve(from + ".jar"); + Path toJson = toDir.resolve(to + ".json"); + Path toJar = toDir.resolve(to + ".jar"); + + boolean hasJarFile = Files.exists(fromJar); + + try { + Files.move(fromJson, toJson); + if (hasJarFile) { + Files.move(fromJar, toJar); + } + } catch (IOException e) { + Lang.ignoringException(() -> Files.move(toJson, fromJson)); + if (hasJarFile) { + Lang.ignoringException(() -> Files.move(toJar, fromJar)); + } + Lang.ignoringException(() -> Files.move(toDir, fromDir)); + throw e; + } + } + + @Override + public boolean hasInstance(GameInstanceID instanceId) { + return status.instances.containsKey(instanceId); + } + + @Override + public GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { + InstanceHolder instanceHolder = status.instances.get(instanceId); + if (instanceHolder == null) { + throw new NoSuchGameInstanceException(instanceId); + } + return instanceHolder.manifest; + } + + @Override + public GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { + Status currentStatus = status; + + InstanceHolder instanceHolder = currentStatus.instances.get(instanceId); + if (instanceHolder == null) { + throw new NoSuchGameInstanceException(instanceId); + } + + GameInstanceManifest.Resolved resolvedManifest = instanceHolder.resolvedManifest; + if (resolvedManifest == null) { + resolvedManifest = currentStatus.resolve(instanceHolder.manifest, new HashSet<>()); + instanceHolder.resolvedManifest = resolvedManifest; + } + return resolvedManifest; + } + + @Override + public int getInstanceCount() { + return status.instances.size(); + } + + @Override + public Collection getInstanceManifests() { + return status.instances.values().stream().map(i -> i.manifest).toList(); + } + + public Path getArtifactFile(GameInstanceManifest manifest, Artifact artifact) { + return artifact.getPath(getLayout().getLibrariesDirectory()); + } + + @Override + public Path getRunDirectory(GameInstanceID instanceId) { + return getBaseDirectory(); + } + + @Override + public Path getInstanceJar(GameInstanceManifest manifest) { + GameInstanceManifest resolved = this.resolve(manifest).launchManifest(); + GameInstanceID id = Optional.ofNullable(resolved.jar()).orElse(resolved.id()); + return getLayout().getInstanceJarFile(id); + } + + @Override + public boolean renameInstance(GameInstanceID from, GameInstanceID to) { + if (EventBus.EVENT_BUS.fireEvent(new RenameInstanceEvent(this, from, to)) == Event.Result.DENY) { + return false; + } + + try { + Status currentStatus = status; + InstanceHolder fromHolder = currentStatus.instances.get(from); + if (fromHolder == null) { + throw new NoSuchGameInstanceException(from); + } + + moveInstanceFiles(currentStatus.layout.getBaseDirectory(), from, to); + + GameInstanceManifest renamedManifest = fromHolder.manifest; + if (from.equals(renamedManifest.jar())) { + renamedManifest = renamedManifest.withJar(null); + } + renamedManifest = renamedManifest.withId(to); + JsonUtils.writeToJsonFile(getInstanceJson(to), renamedManifest); + + Map updatedInstances = new TreeMap<>(currentStatus.instances); + updatedInstances.remove(from); + updatedInstances.put(to, new InstanceHolder(currentStatus, to, renamedManifest)); + + for (InstanceHolder holder : currentStatus.instances.values()) { + GameInstanceManifest manifest = holder.manifest; + if (from.equals(manifest.inheritsFrom())) { + GameInstanceManifest updatedManifest = manifest.withInheritsFrom(to); + Path targetPath = getInstanceJson(updatedManifest.id()); + Files.createDirectories(targetPath.getParent()); + JsonUtils.writeToJsonFile(targetPath, updatedManifest); + updatedInstances.put(updatedManifest.id(), new InstanceHolder(currentStatus, updatedManifest.id(), updatedManifest)); + } + } + + currentStatus.instances.clear(); + currentStatus.instances.putAll(updatedInstances); + gameVersions.clear(); + return true; + } catch (IOException | JsonParseException | NoSuchGameInstanceException | InvalidPathException e) { + LOG.warning("Unable to rename version " + from + " to " + to, e); + return false; + } + } + + public boolean removeInstanceFromDisk(GameInstanceID id) { + if (EventBus.EVENT_BUS.fireEvent(new RemoveInstanceEvent(this, id)) == Event.Result.DENY) { + return false; + } + + Status currentStatus = status; + currentStatus.instances.remove(id); + + Path file = getLayout().getInstanceRoot(id); + if (Files.notExists(file)) { + return true; + } + + Path removedFile = file.toAbsolutePath().resolveSibling(FileUtils.getName(file) + "_removed"); + try { + Files.move(file, removedFile, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + LOG.warning("Unable to remove version folder: " + file, e); + return false; + } + + try { + if (FileUtils.moveToTrash(removedFile)) { + return true; + } + + for (Path path : FileUtils.listFilesByExtension(removedFile, "json")) { + try { + Files.delete(path); + } catch (IOException e) { + LOG.warning("Failed to delete file " + path, e); + } + } + + try { + FileUtils.deleteDirectory(removedFile); + } catch (IOException e) { + LOG.warning("Unable to remove version folder: " + file, e); + } + return true; + } finally { + refreshAsync().start(); + } + } + + @Override + public Optional getGameVersion(GameInstanceManifest manifest) { + try { + GameInstanceManifest resolved = resolve(manifest).launchManifest(); + Path instanceJar = getInstanceJar(resolved); + return gameVersions.computeIfAbsent(instanceJar, jar -> { + Optional gameVersion = GameVersion.minecraftVersion(jar); + if (gameVersion.isEmpty()) { + LOG.warning("Cannot find out game version of " + manifest.id() + + ", primary jar: " + jar + + ", jar exists: " + Files.exists(jar)); + } + return gameVersion; + }); + } catch (NoSuchGameInstanceException e) { + return Optional.empty(); + } + } + + @Override + public Path getNativeDirectory(GameInstanceID instanceId, Platform platform) { + return getLayout().getInstanceRoot(instanceId).resolve("natives-" + platform); + } + + @Override + public Path getModsDirectory(GameInstanceID instanceId) { + return getRunDirectory(instanceId).resolve("mods"); + } + + @Override + public Path getResourcePackDirectory(GameInstanceID instanceId) { + return getRunDirectory(instanceId).resolve("resourcepacks"); + } + + public Path getInstanceJson(GameInstanceID instanceId) { + return getLayout().getInstanceJson(instanceId); + } + + @Override + public AssetIndex getAssetIndex(GameInstanceID instanceId, String assetId) throws IOException { + try { + return Objects.requireNonNull(JsonUtils.fromJsonFile(getLayout().getAssetIndexFile(assetId), AssetIndex.class)); + } catch (JsonParseException | NullPointerException e) { + throw new IOException("Asset index file malformed", e); + } + } + + @Override + public Path getActualAssetDirectory(GameInstanceID instanceId, String assetId) { + try { + return reconstructAssets(instanceId, assetId); + } catch (IOException | JsonParseException e) { + LOG.error("Unable to reconstruct asset directory", e); + return getLayout().getAssetDirectory(); + } + } + + @Override + public Optional getAssetObject(GameInstanceID instanceId, String assetId, String name) throws IOException { + try { + AssetObject assetObject = getAssetIndex(instanceId, assetId).getObjects().get(name); + if (assetObject == null) return Optional.empty(); + return Optional.of(getLayout().getAssetObject(assetObject)); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException("Unrecognized asset object " + name + " in asset " + assetId + " of version " + instanceId, e); + } + } + + public Path getAssetObject(GameInstanceID instanceId, Path assetDir, AssetObject obj) { + return assetDir.resolve("objects").resolve(obj.getLocation()); + } + + protected Path reconstructAssets(GameInstanceID instanceId, String assetId) throws IOException, JsonParseException { + Path assetsDir = getLayout().getAssetDirectory(); + Path indexFile = getLayout().getAssetIndexFile(assetId); + Path virtualRoot = assetsDir.resolve("virtual").resolve(assetId); + + if (!Files.isRegularFile(indexFile)) + return assetsDir; + + AssetIndex index = JsonUtils.fromJsonFile(indexFile, AssetIndex.class); + + if (index == null) + return assetsDir; + + if (index.isVirtual()) { + Path resourcesDir = getRunDirectory(instanceId).resolve("resources"); + + int cnt = 0; + int tot = index.getObjects().size(); + for (Map.Entry entry : index.getObjects().entrySet()) { + Path target = virtualRoot.resolve(entry.getKey()); + Path original = getAssetObject(instanceId, assetsDir, entry.getValue()); + if (Files.exists(original)) { + cnt++; + if (!Files.isRegularFile(target)) + FileUtils.copyFile(original, target); + + if (index.needMapToResources()) { + target = resourcesDir.resolve(entry.getKey()); + if (!Files.isRegularFile(target)) + FileUtils.copyFile(original, target); + } + } + } + + // If the scale new format existent file is lower than 0.1, use the old format. + if (cnt * 10 < tot) + return assetsDir; + else + return virtualRoot; + } + + return assetsDir; + } + + public Task saveAsync(GameInstanceManifest instanceManifest) { + return Task.supplyAsync(() -> { + GameInstanceManifest savedManifest = instanceManifest.isResolvedPreservingPatches() + ? MaintainTask.maintainPreservingPatches(this, instanceManifest) + : instanceManifest; + + Path json = getInstanceJson(savedManifest.id()).toAbsolutePath(); + Files.createDirectories(json.getParent()); + JsonUtils.writeToJsonFile(json, savedManifest); + + Status currentStatus = status; + currentStatus.instances.put(savedManifest.id(), new InstanceHolder(currentStatus, savedManifest.id(), savedManifest)); + gameVersions.clear(); + return savedManifest; + }); + } + + public Path getModpackConfiguration(GameInstanceID instanceId) { + return getLayout().getInstanceRoot(instanceId).resolve("modpack.json"); + } + + @Nullable + public ModpackConfiguration readModpackConfiguration(GameInstanceID instanceId) throws IOException, NoSuchGameInstanceException { + if (!hasInstance(instanceId)) throw new NoSuchGameInstanceException(instanceId); + Path file = getModpackConfiguration(instanceId); + if (Files.notExists(file)) return null; + return JsonUtils.fromJsonFile(file, ModpackConfiguration.class); + } + + public ResourcePackManager getResourcePackManager(GameInstanceID instanceId) { + return new ResourcePackManager(this, instanceId); + } + + @Override + public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { + return status.resolve(manifest, new HashSet<>()); + } + + protected static class Status { + private final DefaultGameRepositoryLayout layout; + private final @Unmodifiable Map instances = new TreeMap<>(); + + protected Status(DefaultGameRepositoryLayout layout) { + this.layout = layout; + } + + private GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, + Set resolvedSoFar) throws NoSuchGameInstanceException { + GameInstanceManifest launchManifest; + GameInstanceManifest standaloneManifest = manifest.isRoot() + ? manifest + : addPatches( + addPatches(new GameInstanceManifest(manifest.id()), List.of(manifest.toPatch())), + manifest.patches()); + + if (manifest.inheritsFrom() == null) { + if (manifest.isRoot()) { + // TODO: Breaking change, require much testing on versions installed with external installer, other launchers, and all kinds of versions. + launchManifest = manifest.patches() != null ? new GameInstanceManifest(manifest.id()).withPatches(manifest.patches()) : manifest; + } else { + launchManifest = manifest; + } + launchManifest = launchManifest.withJar(manifest.jar() == null ? manifest.id() : manifest.jar()); + } else { + // To maximize the compatibility. + if (!resolvedSoFar.add(manifest.id())) { + LOG.warning("Found circular dependency versions: " + resolvedSoFar); + launchManifest = (manifest.jar() == null ? manifest.withJar(manifest.id()) : manifest) + .withInheritsFrom(null); + } else { + InstanceHolder parentInstance = instances.get(manifest.inheritsFrom()); + if (parentInstance == null) { + throw new NoSuchGameInstanceException(manifest.inheritsFrom()); + } + + // It is supposed to auto-install a version in getVersion. + GameInstanceManifest.Resolved parentResolved = resolve(parentInstance.manifest, resolvedSoFar); + launchManifest = manifest.merge(parentResolved.launchManifest()); + standaloneManifest = addPatches( + addPatches(parentResolved.standaloneManifest(), Collections.singleton(manifest.toPatch())), + manifest.patches()); + } + } + + if (manifest.patches() != null && !manifest.patches().isEmpty()) { + // Assume patches themselves do not have patches recursively. + List sortedPatches = manifest.patches().stream() + .sorted(Comparator.comparing(GameInstancePatch::getPriority)) + .toList(); + for (GameInstancePatch patch : sortedPatches) { + launchManifest = patch.merge(launchManifest); + } + } + + launchManifest = launchManifest.withId(manifest.id()).withPatches(null); + standaloneManifest = standaloneManifest.withId(manifest.id()); + if (launchManifest.jar() != null) { + standaloneManifest = standaloneManifest.withJar(launchManifest.jar()); + } + + return new GameInstanceManifest.Resolved(manifest, launchManifest, standaloneManifest); + } + + private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @Nullable Collection additional) { + if (additional == null || additional.isEmpty()) { + return manifest; + } + + Set patchIds = new HashSet<>(); + for (GameInstancePatch patch : additional) { + if (patch.id() != null) { + patchIds.add(patch.id()); + } + } + + List patches = new ArrayList<>(); + if (manifest.patches() != null) { + for (GameInstancePatch patch : manifest.patches()) { + if (patch.id() == null || !patchIds.contains(patch.id())) { + patches.add(patch); + } + } + } + patches.addAll(additional); + return manifest.withPatches(patches); + } + + } + + protected static class InstanceHolder { + protected final Status status; + protected final GameInstanceID id; + protected final GameInstanceManifest manifest; + protected @Nullable GameInstanceManifest.Resolved resolvedManifest; + protected @Nullable GameVersionNumber version; + + protected InstanceHolder(Status status, GameInstanceID id, GameInstanceManifest manifest) { + this.status = status; + this.id = id; + this.manifest = manifest; + } + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java new file mode 100644 index 00000000000..6befa6b5179 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -0,0 +1,114 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.util.platform.Platform; +import org.jetbrains.annotations.NotNullByDefault; + +import java.nio.file.Path; + +/// Provides an immutable view of a game instance and its instance-specific paths. +/// +/// Core repository implementations replace instances as complete values when repository state +/// changes. Callers that need a long-lived identity must use a higher-level implementation that +/// explicitly provides that guarantee. +@NotNullByDefault +public interface GameInstance { + + GameRepository getRepository(); + + /// Returns the instance ID. + /// + /// @return the instance ID + GameInstanceID getId(); + + /// Returns the manifest read from this instance's manifest file. + /// + /// @return the unresolved stored manifest + GameInstanceManifest getManifest(); + + /// Returns the eagerly resolved manifest views captured with this instance. + /// + /// @return the resolved manifest views + GameInstanceManifest.Resolved getResolvedManifest(); + + /// Returns the manifest used by launch-time consumers. + /// + /// @return the launch manifest + default GameInstanceManifest getLaunchManifest() { + return getResolvedManifest().launchManifest(); + } + + /// Returns the directory containing files owned by this instance. + /// + /// @return the instance root directory + Path getInstanceRoot(); + + /// Returns the primary client jar selected by the resolved launch manifest. + /// + /// @return the primary client jar path + Path getInstanceJarFile(); + + /// Returns the working directory used to run this instance. + /// + /// @return the run directory + Path getRunDirectory(); + + /// Returns the directory containing mods used by this instance. + /// + /// @return the mods directory below the run directory + default Path getModsDirectory() { + return getRunDirectory().resolve("mods"); + } + + /// Returns the directory containing resource packs used by this instance. + /// + /// @return the resource pack directory below the run directory + default Path getResourcePackDirectory() { + return getRunDirectory().resolve("resourcepacks"); + } + + /// Returns the directory containing saved worlds used by this instance. + /// + /// @return the saves directory below the run directory + default Path getSavesDirectory() { + return getRunDirectory().resolve("saves"); + } + + /// Returns the directory containing world backups used by this instance. + /// + /// @return the backups directory below the run directory + default Path getBackupsDirectory() { + return getRunDirectory().resolve("backups"); + } + + /// Returns the directory containing schematics used by this instance. + /// + /// @return the schematics directory below the run directory + default Path getSchematicsDirectory() { + return getRunDirectory().resolve("schematics"); + } + + /// Returns the directory used for extracted native libraries for a platform. + /// + /// @param platform the target platform + /// @return the platform-specific native directory below the instance root + default Path getNativeDirectory(Platform platform) { + return getInstanceRoot().resolve("natives-" + platform); + } +} From 652af311deaa44cc846aa3f29510634439d0bc82 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 3 Aug 2026 21:14:42 +0800 Subject: [PATCH 005/199] update --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 72 +++++++++++++++++++ .../org/jackhuang/hmcl/game/GameInstance.java | 2 + 2 files changed, 74 insertions(+) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java new file mode 100644 index 00000000000..a49f5198560 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -0,0 +1,72 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +@NotNullByDefault +public class HMCLGameInstance extends DefaultGameInstance { + protected HMCLGameInstance(DefaultGameRepository.Status status, DefaultGameRepository repository, DefaultGameRepositoryLayout layout, GameInstanceID id, GameInstanceManifest manifest) { + super(status, repository, layout, id, manifest); + } + + @Override + public HMCLGameRepository getRepository() { + return (HMCLGameRepository) super.getRepository(); + } + + @NotNullByDefault + public static final class Optional { + private final HMCLGameRepository repository; + private final @Nullable HMCLGameInstance instance; + + public Optional(HMCLGameRepository repository) { + this.repository = repository; + this.instance = null; + } + + public Optional(HMCLGameInstance instance) { + this.repository = instance.getRepository(); + this.instance = instance; + } + + public HMCLGameRepository repository() { + return repository; + } + + @Contract(pure = true) + public @Nullable HMCLGameInstance instance() { + return instance; + } + + @Contract(pure = true) + public @Nullable GameInstanceID instanceId() { + return instance != null ? instance.getId() : null; + } + + public boolean isPresent() { + return instance != null; + } + + public boolean isEmpty() { + return instance == null; + } + } +} 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 6befa6b5179..226d085405a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -32,6 +32,8 @@ public interface GameInstance { GameRepository getRepository(); + GameRepositoryLayout getLayout(); + /// Returns the instance ID. /// /// @return the instance ID From 4cf23917e38f5fef1807cde666585f1f26305603 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 3 Aug 2026 21:15:14 +0800 Subject: [PATCH 006/199] update --- .../hmcl/game/DefaultGameRepository2.java | 679 ------------------ 1 file changed, 679 deletions(-) delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository2.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository2.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository2.java deleted file mode 100644 index 7a6a9822582..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository2.java +++ /dev/null @@ -1,679 +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.game; - -import com.google.gson.JsonParseException; -import org.jackhuang.hmcl.addon.mod.ModManager; -import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; -import org.jackhuang.hmcl.download.MaintainTask; -import org.jackhuang.hmcl.event.*; -import org.jackhuang.hmcl.modpack.ModpackConfiguration; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.platform.Platform; -import org.jackhuang.hmcl.util.versioning.GameVersionNumber; -import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.InvalidPathException; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.stream.Stream; - -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -@NotNullByDefault -public class DefaultGameRepository2 implements GameRepository { - - private static final GameInstanceManifest CLASSIC_MANIFEST = new GameInstanceManifest( - new GameInstanceID("Classic"), - "${auth_player_name} ${auth_session} --workDir ${game_directory}", - null, - "net.minecraft.client.Minecraft", - null, - null, - null, - null, - null, - null, - List.of( - classicLibrary("lwjgl"), - classicLibrary("jinput"), - classicLibrary("lwjgl_util")), - null, - null, - null, - ReleaseType.UNKNOWN, - null, - null, - 0, - false, - false, - null, - null - ); - - private static Library classicLibrary(String name) { - return new Library(new Artifact("", "", ""), null, - new LibrariesDownloadInfo(new LibraryDownloadInfo("bin/" + name + ".jar"), null), - null, null, null, null, null, null); - } - - private static boolean hasClassicVersion(Path baseDirectory) { - Path bin = baseDirectory.resolve("bin"); - return Files.isDirectory(bin) - && Files.exists(bin.resolve("lwjgl.jar")) - && Files.exists(bin.resolve("jinput.jar")) - && Files.exists(bin.resolve("lwjgl_util.jar")); - } - - private volatile Status status; - private volatile boolean loaded; - private final ConcurrentHashMap> gameVersions = new ConcurrentHashMap<>(); - - public DefaultGameRepository2(Path baseDirectory) { - this.status = new Status(new DefaultGameRepositoryLayout(baseDirectory)); - } - - public Path getBaseDirectory() { - return status.layout.getBaseDirectory(); - } - - public void setBaseDirectory(Path baseDirectory) { - this.status = new Status(new DefaultGameRepositoryLayout(baseDirectory)); - this.loaded = false; - this.gameVersions.clear(); - } - - @Override - public GameRepositoryLayout getLayout() { - return status.layout; - } - - public boolean isLoaded() { - return loaded; - } - - @Override - public void refresh() { - if (EventBus.EVENT_BUS.fireEvent(new RefreshingInstancesEvent(this)) == Event.Result.DENY) { - return; - } - - refreshImpl(); - loaded = true; - EventBus.EVENT_BUS.fireEvent(new RefreshedGameInstancesEvent(this)); - } - - protected void refreshImpl() { - Status newStatus = new Status(status.layout); - - if (hasClassicVersion(newStatus.layout.getBaseDirectory())) { - GameInstanceID id = CLASSIC_MANIFEST.id(); - newStatus.instances.put(id, new InstanceHolder(newStatus, id, CLASSIC_MANIFEST)); - } - - Path versionsDir = newStatus.layout.getBaseDirectory().resolve("versions"); - if (Files.isDirectory(versionsDir)) { - try (Stream stream = Files.list(versionsDir)) { - stream.parallel().filter(Files::isDirectory).flatMap(dir -> { - GameInstanceID id; - try { - id = new GameInstanceID(FileUtils.getName(dir)); - } catch (IllegalArgumentException e) { - LOG.warning("Ignoring version folder with invalid id " + dir, e); - return Stream.empty(); - } - - Path json = dir.resolve(id + ".json"); - - if (Files.notExists(json)) { - List jsons = FileUtils.listFilesByExtension(dir, "json"); - if (jsons.size() == 1) { - LOG.info("Renaming json file " + jsons.get(0) + " to " + json); - - try { - Files.move(jsons.get(0), json); - } catch (IOException e) { - LOG.warning("Cannot rename json file, ignoring version " + id, e); - return Stream.empty(); - } - - Path jar = dir.resolve(FileUtils.getNameWithoutExtension(jsons.get(0)) + ".jar"); - if (Files.exists(jar)) { - try { - Files.move(jar, dir.resolve(id + ".jar")); - } catch (IOException e) { - LOG.warning("Cannot rename jar file, ignoring version " + id, e); - return Stream.empty(); - } - } - } else { - LOG.info("No available json file found, ignoring version " + id); - return Stream.empty(); - } - } - - GameInstanceManifest manifest; - try { - manifest = readInstanceManifest(json); - } catch (Exception e) { - LOG.warning("Malformed version json " + id, e); - if (EventBus.EVENT_BUS.fireEvent(new GameJsonParseFailedEvent(this, json, id.id())) != Event.Result.ALLOW) { - return Stream.empty(); - } - - try { - manifest = readInstanceManifest(json); - } catch (Exception e2) { - LOG.error("User corrected version json is still malformed", e2); - return Stream.empty(); - } - } - - if (!id.equals(manifest.id())) { - try { - moveInstanceFiles(newStatus.layout.getBaseDirectory(), id, manifest.id()); - } catch (IOException e) { - LOG.warning("Ignoring instance " + manifest.id() - + " because instance id does not match folder name " + id - + ", and we cannot correct it.", e); - return Stream.empty(); - } - } - - return Stream.of(manifest); - }).forEachOrdered(it -> newStatus.instances.put( - it.id(), - new InstanceHolder(newStatus, it.id(), it))); - } catch (IOException e) { - LOG.warning("Failed to load versions from " + versionsDir, e); - } - } - - Map loadedInstances = new TreeMap<>(); - for (InstanceHolder holder : newStatus.instances.values()) { - try { - GameInstanceManifest resolved = newStatus.resolve(holder.manifest, new HashSet<>()).launchManifest(); - if (CompatibilityRule.appliesToCurrentEnvironment(resolved.compatibilityRules())) { - loadedInstances.put(holder.id, holder); - } - } catch (NoSuchGameInstanceException e) { - LOG.warning("Ignoring version " + holder.id + " because it inherits from a nonexistent version."); - } - } - - newStatus.instances.clear(); - newStatus.instances.putAll(loadedInstances); - gameVersions.clear(); - this.status = newStatus; - } - - private static GameInstanceManifest readInstanceManifest(Path json) throws IOException, JsonParseException { - GameInstanceManifest manifest = JsonUtils.fromJsonFile(json, GameInstanceManifest.class); - if (manifest == null) { - throw new JsonParseException("Manifest is null"); - } - return manifest; - } - - private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, GameInstanceID to) throws IOException { - Path versionsDir = baseDirectory.resolve("versions"); - Path fromDir = versionsDir.resolve(from.id()); - Path toDir = versionsDir.resolve(to.id()); - Files.move(fromDir, toDir); - - Path fromJson = toDir.resolve(from + ".json"); - Path fromJar = toDir.resolve(from + ".jar"); - Path toJson = toDir.resolve(to + ".json"); - Path toJar = toDir.resolve(to + ".jar"); - - boolean hasJarFile = Files.exists(fromJar); - - try { - Files.move(fromJson, toJson); - if (hasJarFile) { - Files.move(fromJar, toJar); - } - } catch (IOException e) { - Lang.ignoringException(() -> Files.move(toJson, fromJson)); - if (hasJarFile) { - Lang.ignoringException(() -> Files.move(toJar, fromJar)); - } - Lang.ignoringException(() -> Files.move(toDir, fromDir)); - throw e; - } - } - - @Override - public boolean hasInstance(GameInstanceID instanceId) { - return status.instances.containsKey(instanceId); - } - - @Override - public GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { - InstanceHolder instanceHolder = status.instances.get(instanceId); - if (instanceHolder == null) { - throw new NoSuchGameInstanceException(instanceId); - } - return instanceHolder.manifest; - } - - @Override - public GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { - Status currentStatus = status; - - InstanceHolder instanceHolder = currentStatus.instances.get(instanceId); - if (instanceHolder == null) { - throw new NoSuchGameInstanceException(instanceId); - } - - GameInstanceManifest.Resolved resolvedManifest = instanceHolder.resolvedManifest; - if (resolvedManifest == null) { - resolvedManifest = currentStatus.resolve(instanceHolder.manifest, new HashSet<>()); - instanceHolder.resolvedManifest = resolvedManifest; - } - return resolvedManifest; - } - - @Override - public int getInstanceCount() { - return status.instances.size(); - } - - @Override - public Collection getInstanceManifests() { - return status.instances.values().stream().map(i -> i.manifest).toList(); - } - - public Path getArtifactFile(GameInstanceManifest manifest, Artifact artifact) { - return artifact.getPath(getLayout().getLibrariesDirectory()); - } - - @Override - public Path getRunDirectory(GameInstanceID instanceId) { - return getBaseDirectory(); - } - - @Override - public Path getInstanceJar(GameInstanceManifest manifest) { - GameInstanceManifest resolved = this.resolve(manifest).launchManifest(); - GameInstanceID id = Optional.ofNullable(resolved.jar()).orElse(resolved.id()); - return getLayout().getInstanceJarFile(id); - } - - @Override - public boolean renameInstance(GameInstanceID from, GameInstanceID to) { - if (EventBus.EVENT_BUS.fireEvent(new RenameInstanceEvent(this, from, to)) == Event.Result.DENY) { - return false; - } - - try { - Status currentStatus = status; - InstanceHolder fromHolder = currentStatus.instances.get(from); - if (fromHolder == null) { - throw new NoSuchGameInstanceException(from); - } - - moveInstanceFiles(currentStatus.layout.getBaseDirectory(), from, to); - - GameInstanceManifest renamedManifest = fromHolder.manifest; - if (from.equals(renamedManifest.jar())) { - renamedManifest = renamedManifest.withJar(null); - } - renamedManifest = renamedManifest.withId(to); - JsonUtils.writeToJsonFile(getInstanceJson(to), renamedManifest); - - Map updatedInstances = new TreeMap<>(currentStatus.instances); - updatedInstances.remove(from); - updatedInstances.put(to, new InstanceHolder(currentStatus, to, renamedManifest)); - - for (InstanceHolder holder : currentStatus.instances.values()) { - GameInstanceManifest manifest = holder.manifest; - if (from.equals(manifest.inheritsFrom())) { - GameInstanceManifest updatedManifest = manifest.withInheritsFrom(to); - Path targetPath = getInstanceJson(updatedManifest.id()); - Files.createDirectories(targetPath.getParent()); - JsonUtils.writeToJsonFile(targetPath, updatedManifest); - updatedInstances.put(updatedManifest.id(), new InstanceHolder(currentStatus, updatedManifest.id(), updatedManifest)); - } - } - - currentStatus.instances.clear(); - currentStatus.instances.putAll(updatedInstances); - gameVersions.clear(); - return true; - } catch (IOException | JsonParseException | NoSuchGameInstanceException | InvalidPathException e) { - LOG.warning("Unable to rename version " + from + " to " + to, e); - return false; - } - } - - public boolean removeInstanceFromDisk(GameInstanceID id) { - if (EventBus.EVENT_BUS.fireEvent(new RemoveInstanceEvent(this, id)) == Event.Result.DENY) { - return false; - } - - Status currentStatus = status; - currentStatus.instances.remove(id); - - Path file = getLayout().getInstanceRoot(id); - if (Files.notExists(file)) { - return true; - } - - Path removedFile = file.toAbsolutePath().resolveSibling(FileUtils.getName(file) + "_removed"); - try { - Files.move(file, removedFile, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - LOG.warning("Unable to remove version folder: " + file, e); - return false; - } - - try { - if (FileUtils.moveToTrash(removedFile)) { - return true; - } - - for (Path path : FileUtils.listFilesByExtension(removedFile, "json")) { - try { - Files.delete(path); - } catch (IOException e) { - LOG.warning("Failed to delete file " + path, e); - } - } - - try { - FileUtils.deleteDirectory(removedFile); - } catch (IOException e) { - LOG.warning("Unable to remove version folder: " + file, e); - } - return true; - } finally { - refreshAsync().start(); - } - } - - @Override - public Optional getGameVersion(GameInstanceManifest manifest) { - try { - GameInstanceManifest resolved = resolve(manifest).launchManifest(); - Path instanceJar = getInstanceJar(resolved); - return gameVersions.computeIfAbsent(instanceJar, jar -> { - Optional gameVersion = GameVersion.minecraftVersion(jar); - if (gameVersion.isEmpty()) { - LOG.warning("Cannot find out game version of " + manifest.id() - + ", primary jar: " + jar - + ", jar exists: " + Files.exists(jar)); - } - return gameVersion; - }); - } catch (NoSuchGameInstanceException e) { - return Optional.empty(); - } - } - - @Override - public Path getNativeDirectory(GameInstanceID instanceId, Platform platform) { - return getLayout().getInstanceRoot(instanceId).resolve("natives-" + platform); - } - - @Override - public Path getModsDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("mods"); - } - - @Override - public Path getResourcePackDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("resourcepacks"); - } - - public Path getInstanceJson(GameInstanceID instanceId) { - return getLayout().getInstanceJson(instanceId); - } - - @Override - public AssetIndex getAssetIndex(GameInstanceID instanceId, String assetId) throws IOException { - try { - return Objects.requireNonNull(JsonUtils.fromJsonFile(getLayout().getAssetIndexFile(assetId), AssetIndex.class)); - } catch (JsonParseException | NullPointerException e) { - throw new IOException("Asset index file malformed", e); - } - } - - @Override - public Path getActualAssetDirectory(GameInstanceID instanceId, String assetId) { - try { - return reconstructAssets(instanceId, assetId); - } catch (IOException | JsonParseException e) { - LOG.error("Unable to reconstruct asset directory", e); - return getLayout().getAssetDirectory(); - } - } - - @Override - public Optional getAssetObject(GameInstanceID instanceId, String assetId, String name) throws IOException { - try { - AssetObject assetObject = getAssetIndex(instanceId, assetId).getObjects().get(name); - if (assetObject == null) return Optional.empty(); - return Optional.of(getLayout().getAssetObject(assetObject)); - } catch (IOException e) { - throw e; - } catch (Exception e) { - throw new IOException("Unrecognized asset object " + name + " in asset " + assetId + " of version " + instanceId, e); - } - } - - public Path getAssetObject(GameInstanceID instanceId, Path assetDir, AssetObject obj) { - return assetDir.resolve("objects").resolve(obj.getLocation()); - } - - protected Path reconstructAssets(GameInstanceID instanceId, String assetId) throws IOException, JsonParseException { - Path assetsDir = getLayout().getAssetDirectory(); - Path indexFile = getLayout().getAssetIndexFile(assetId); - Path virtualRoot = assetsDir.resolve("virtual").resolve(assetId); - - if (!Files.isRegularFile(indexFile)) - return assetsDir; - - AssetIndex index = JsonUtils.fromJsonFile(indexFile, AssetIndex.class); - - if (index == null) - return assetsDir; - - if (index.isVirtual()) { - Path resourcesDir = getRunDirectory(instanceId).resolve("resources"); - - int cnt = 0; - int tot = index.getObjects().size(); - for (Map.Entry entry : index.getObjects().entrySet()) { - Path target = virtualRoot.resolve(entry.getKey()); - Path original = getAssetObject(instanceId, assetsDir, entry.getValue()); - if (Files.exists(original)) { - cnt++; - if (!Files.isRegularFile(target)) - FileUtils.copyFile(original, target); - - if (index.needMapToResources()) { - target = resourcesDir.resolve(entry.getKey()); - if (!Files.isRegularFile(target)) - FileUtils.copyFile(original, target); - } - } - } - - // If the scale new format existent file is lower than 0.1, use the old format. - if (cnt * 10 < tot) - return assetsDir; - else - return virtualRoot; - } - - return assetsDir; - } - - public Task saveAsync(GameInstanceManifest instanceManifest) { - return Task.supplyAsync(() -> { - GameInstanceManifest savedManifest = instanceManifest.isResolvedPreservingPatches() - ? MaintainTask.maintainPreservingPatches(this, instanceManifest) - : instanceManifest; - - Path json = getInstanceJson(savedManifest.id()).toAbsolutePath(); - Files.createDirectories(json.getParent()); - JsonUtils.writeToJsonFile(json, savedManifest); - - Status currentStatus = status; - currentStatus.instances.put(savedManifest.id(), new InstanceHolder(currentStatus, savedManifest.id(), savedManifest)); - gameVersions.clear(); - return savedManifest; - }); - } - - public Path getModpackConfiguration(GameInstanceID instanceId) { - return getLayout().getInstanceRoot(instanceId).resolve("modpack.json"); - } - - @Nullable - public ModpackConfiguration readModpackConfiguration(GameInstanceID instanceId) throws IOException, NoSuchGameInstanceException { - if (!hasInstance(instanceId)) throw new NoSuchGameInstanceException(instanceId); - Path file = getModpackConfiguration(instanceId); - if (Files.notExists(file)) return null; - return JsonUtils.fromJsonFile(file, ModpackConfiguration.class); - } - - public ResourcePackManager getResourcePackManager(GameInstanceID instanceId) { - return new ResourcePackManager(this, instanceId); - } - - @Override - public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { - return status.resolve(manifest, new HashSet<>()); - } - - protected static class Status { - private final DefaultGameRepositoryLayout layout; - private final @Unmodifiable Map instances = new TreeMap<>(); - - protected Status(DefaultGameRepositoryLayout layout) { - this.layout = layout; - } - - private GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, - Set resolvedSoFar) throws NoSuchGameInstanceException { - GameInstanceManifest launchManifest; - GameInstanceManifest standaloneManifest = manifest.isRoot() - ? manifest - : addPatches( - addPatches(new GameInstanceManifest(manifest.id()), List.of(manifest.toPatch())), - manifest.patches()); - - if (manifest.inheritsFrom() == null) { - if (manifest.isRoot()) { - // TODO: Breaking change, require much testing on versions installed with external installer, other launchers, and all kinds of versions. - launchManifest = manifest.patches() != null ? new GameInstanceManifest(manifest.id()).withPatches(manifest.patches()) : manifest; - } else { - launchManifest = manifest; - } - launchManifest = launchManifest.withJar(manifest.jar() == null ? manifest.id() : manifest.jar()); - } else { - // To maximize the compatibility. - if (!resolvedSoFar.add(manifest.id())) { - LOG.warning("Found circular dependency versions: " + resolvedSoFar); - launchManifest = (manifest.jar() == null ? manifest.withJar(manifest.id()) : manifest) - .withInheritsFrom(null); - } else { - InstanceHolder parentInstance = instances.get(manifest.inheritsFrom()); - if (parentInstance == null) { - throw new NoSuchGameInstanceException(manifest.inheritsFrom()); - } - - // It is supposed to auto-install a version in getVersion. - GameInstanceManifest.Resolved parentResolved = resolve(parentInstance.manifest, resolvedSoFar); - launchManifest = manifest.merge(parentResolved.launchManifest()); - standaloneManifest = addPatches( - addPatches(parentResolved.standaloneManifest(), Collections.singleton(manifest.toPatch())), - manifest.patches()); - } - } - - if (manifest.patches() != null && !manifest.patches().isEmpty()) { - // Assume patches themselves do not have patches recursively. - List sortedPatches = manifest.patches().stream() - .sorted(Comparator.comparing(GameInstancePatch::getPriority)) - .toList(); - for (GameInstancePatch patch : sortedPatches) { - launchManifest = patch.merge(launchManifest); - } - } - - launchManifest = launchManifest.withId(manifest.id()).withPatches(null); - standaloneManifest = standaloneManifest.withId(manifest.id()); - if (launchManifest.jar() != null) { - standaloneManifest = standaloneManifest.withJar(launchManifest.jar()); - } - - return new GameInstanceManifest.Resolved(manifest, launchManifest, standaloneManifest); - } - - private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @Nullable Collection additional) { - if (additional == null || additional.isEmpty()) { - return manifest; - } - - Set patchIds = new HashSet<>(); - for (GameInstancePatch patch : additional) { - if (patch.id() != null) { - patchIds.add(patch.id()); - } - } - - List patches = new ArrayList<>(); - if (manifest.patches() != null) { - for (GameInstancePatch patch : manifest.patches()) { - if (patch.id() == null || !patchIds.contains(patch.id())) { - patches.add(patch); - } - } - } - patches.addAll(additional); - return manifest.withPatches(patches); - } - - } - - protected static class InstanceHolder { - protected final Status status; - protected final GameInstanceID id; - protected final GameInstanceManifest manifest; - protected @Nullable GameInstanceManifest.Resolved resolvedManifest; - protected @Nullable GameVersionNumber version; - - protected InstanceHolder(Status status, GameInstanceID id, GameInstanceManifest manifest) { - this.status = status; - this.id = id; - this.manifest = manifest; - } - } -} From 79ad33925e39cda8424e1625d46bc41122c3cb3b Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 3 Aug 2026 21:17:44 +0800 Subject: [PATCH 007/199] feat: add version management to DefaultGameInstance and GameInstance interface --- .../hmcl/game/DefaultGameInstance.java | 17 ++++++++++++++++- .../org/jackhuang/hmcl/game/GameInstance.java | 3 +++ 2 files changed, 19 insertions(+), 1 deletion(-) 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 64c7a81d8f4..63663c27532 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.game; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -30,6 +31,7 @@ public class DefaultGameInstance implements GameInstance { private final GameInstanceID id; private final GameInstanceManifest manifest; private GameInstanceManifest.@Nullable Resolved resolvedManifest; + private @Nullable GameVersionNumber version; protected DefaultGameInstance( DefaultGameRepository.Status status, @@ -42,10 +44,15 @@ protected DefaultGameInstance( } @Override - public GameRepository getRepository() { + public DefaultGameRepository getRepository() { return repository; } + @Override + public DefaultGameRepositoryLayout getLayout() { + return layout; + } + @Override public GameInstanceID getId() { return id; @@ -65,6 +72,14 @@ public GameInstanceManifest.Resolved getResolvedManifest() { return resolvedManifest; } + @Override + public GameVersionNumber getVersion() { + if (version == null) { + version = GameVersionNumber.asGameVersion(repository.getGameVersion(getId())); // TODO + } + return version; + } + @Override public Path getInstanceRoot() { return layout.getInstanceRoot(id); 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 226d085405a..361e2f16612 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.util.platform.Platform; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import java.nio.file.Path; @@ -56,6 +57,8 @@ default GameInstanceManifest getLaunchManifest() { return getResolvedManifest().launchManifest(); } + GameVersionNumber getVersion(); + /// Returns the directory containing files owned by this instance. /// /// @return the instance root directory From ec65193e054ea682b31c75abcbeab9c63de75e96 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 3 Aug 2026 21:36:05 +0800 Subject: [PATCH 008/199] refactor: simplify DefaultGameInstance and DefaultGameRepository structure --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 2 +- .../hmcl/game/DefaultGameInstance.java | 24 +++-- .../hmcl/game/DefaultGameRepository.java | 100 ++++++++++-------- .../jackhuang/hmcl/game/GameRepository.java | 3 + 4 files changed, 73 insertions(+), 56 deletions(-) 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 a49f5198560..812fe42fb69 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -24,7 +24,7 @@ @NotNullByDefault public class HMCLGameInstance extends DefaultGameInstance { protected HMCLGameInstance(DefaultGameRepository.Status status, DefaultGameRepository repository, DefaultGameRepositoryLayout layout, GameInstanceID id, GameInstanceManifest manifest) { - super(status, repository, layout, id, manifest); + super(status, id, manifest); } @Override 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 63663c27532..d4a1ca7e173 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -26,23 +26,27 @@ @NotNullByDefault public class DefaultGameInstance implements GameInstance { - private final DefaultGameRepository repository; - private final DefaultGameRepositoryLayout layout; - private final GameInstanceID id; - private final GameInstanceManifest manifest; - private GameInstanceManifest.@Nullable Resolved resolvedManifest; - private @Nullable GameVersionNumber version; + protected final DefaultGameRepository repository; + protected final DefaultGameRepositoryLayout layout; + protected final GameInstanceID id; + protected final GameInstanceManifest manifest; + protected GameInstanceManifest.@Nullable Resolved resolvedManifest; + protected @Nullable GameVersionNumber version; protected DefaultGameInstance( DefaultGameRepository.Status status, - DefaultGameRepository repository, DefaultGameRepositoryLayout layout, - GameInstanceID id, GameInstanceManifest manifest) { - this.repository = repository; - this.layout = layout; + GameInstanceID id, + GameInstanceManifest manifest) { + this.repository = status.repository; + this.layout = status.layout; this.id = id; this.manifest = manifest; } + protected DefaultGameInstance withNewStatus(DefaultGameRepository.Status newStatus) { + return new DefaultGameInstance(newStatus, id, manifest); + } + @Override public DefaultGameRepository getRepository() { return repository; 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 e26ebd528ea..63558d6e024 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -28,7 +28,6 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.Platform; -import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -93,7 +92,7 @@ private static boolean hasClassicVersion(Path baseDirectory) { private final ConcurrentHashMap> gameVersions = new ConcurrentHashMap<>(); public DefaultGameRepository(Path baseDirectory) { - this.status = new Status(new DefaultGameRepositoryLayout(baseDirectory)); + this.status = new Status(this, new DefaultGameRepositoryLayout(baseDirectory)); } public Path getBaseDirectory() { @@ -101,7 +100,7 @@ public Path getBaseDirectory() { } public void setBaseDirectory(Path baseDirectory) { - this.status = new Status(new DefaultGameRepositoryLayout(baseDirectory)); + this.status = new Status(this, new DefaultGameRepositoryLayout(baseDirectory)); this.loaded = false; this.gameVersions.clear(); } @@ -127,11 +126,11 @@ public void refresh() { } protected void refreshImpl() { - Status newStatus = new Status(status.layout); + Status newStatus = new Status(this, status.layout); if (hasClassicVersion(newStatus.layout.getBaseDirectory())) { GameInstanceID id = CLASSIC_MANIFEST.id(); - newStatus.instances.put(id, new InstanceHolder(newStatus, id, CLASSIC_MANIFEST)); + newStatus.instances.put(id, createInstance(newStatus, id, CLASSIC_MANIFEST)); } Path versionsDir = newStatus.layout.getBaseDirectory().resolve("versions"); @@ -206,21 +205,21 @@ protected void refreshImpl() { return Stream.of(manifest); }).forEachOrdered(it -> newStatus.instances.put( it.id(), - new InstanceHolder(newStatus, it.id(), it))); + createInstance(newStatus, it.id(), it))); } catch (IOException e) { LOG.warning("Failed to load versions from " + versionsDir, e); } } - Map loadedInstances = new TreeMap<>(); - for (InstanceHolder holder : newStatus.instances.values()) { + Map loadedInstances = new TreeMap<>(); + for (DefaultGameInstance instance : newStatus.instances.values()) { try { - GameInstanceManifest resolved = newStatus.resolve(holder.manifest, new HashSet<>()).launchManifest(); + GameInstanceManifest resolved = newStatus.resolve(instance.getManifest(), new HashSet<>()).launchManifest(); if (CompatibilityRule.appliesToCurrentEnvironment(resolved.compatibilityRules())) { - loadedInstances.put(holder.id, holder); + loadedInstances.put(instance.getId(), instance); } } catch (NoSuchGameInstanceException e) { - LOG.warning("Ignoring version " + holder.id + " because it inherits from a nonexistent version."); + LOG.warning("Ignoring instance " + instance.getId() + " because it inherits from a nonexistent version."); } } @@ -273,26 +272,26 @@ public boolean hasInstance(GameInstanceID instanceId) { @Override public GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { - InstanceHolder instanceHolder = status.instances.get(instanceId); - if (instanceHolder == null) { + DefaultGameInstance instance = status.instances.get(instanceId); + if (instance == null) { throw new NoSuchGameInstanceException(instanceId); } - return instanceHolder.manifest; + return instance.getManifest(); } @Override public GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { Status currentStatus = status; - InstanceHolder instanceHolder = currentStatus.instances.get(instanceId); - if (instanceHolder == null) { + DefaultGameInstance instance = currentStatus.instances.get(instanceId); + if (instance == null) { throw new NoSuchGameInstanceException(instanceId); } - GameInstanceManifest.Resolved resolvedManifest = instanceHolder.resolvedManifest; + GameInstanceManifest.Resolved resolvedManifest = instance.resolvedManifest; if (resolvedManifest == null) { - resolvedManifest = currentStatus.resolve(instanceHolder.manifest, new HashSet<>()); - instanceHolder.resolvedManifest = resolvedManifest; + resolvedManifest = currentStatus.resolve(instance.manifest, new HashSet<>()); + instance.resolvedManifest = resolvedManifest; } return resolvedManifest; } @@ -307,6 +306,11 @@ public Collection getInstanceManifests() { return status.instances.values().stream().map(i -> i.manifest).toList(); } + @Override + public @Nullable GameInstance getInstance(GameInstanceID id) { + return null; + } + public Path getArtifactFile(GameInstanceManifest manifest, Artifact artifact) { return artifact.getPath(getLayout().getLibrariesDirectory()); } @@ -331,7 +335,7 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { try { Status currentStatus = status; - InstanceHolder fromHolder = currentStatus.instances.get(from); + DefaultGameInstance fromHolder = currentStatus.instances.get(from); if (fromHolder == null) { throw new NoSuchGameInstanceException(from); } @@ -345,18 +349,18 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { renamedManifest = renamedManifest.withId(to); JsonUtils.writeToJsonFile(getInstanceJson(to), renamedManifest); - Map updatedInstances = new TreeMap<>(currentStatus.instances); + Map updatedInstances = new TreeMap<>(currentStatus.instances); updatedInstances.remove(from); - updatedInstances.put(to, new InstanceHolder(currentStatus, to, renamedManifest)); + updatedInstances.put(to, createInstance(currentStatus, to, renamedManifest)); - for (InstanceHolder holder : currentStatus.instances.values()) { - GameInstanceManifest manifest = holder.manifest; + for (DefaultGameInstance instance : currentStatus.instances.values()) { + GameInstanceManifest manifest = instance.manifest; if (from.equals(manifest.inheritsFrom())) { GameInstanceManifest updatedManifest = manifest.withInheritsFrom(to); Path targetPath = getInstanceJson(updatedManifest.id()); Files.createDirectories(targetPath.getParent()); JsonUtils.writeToJsonFile(targetPath, updatedManifest); - updatedInstances.put(updatedManifest.id(), new InstanceHolder(currentStatus, updatedManifest.id(), updatedManifest)); + updatedInstances.put(updatedManifest.id(), createInstance(currentStatus, updatedManifest.id(), updatedManifest)); } } @@ -543,8 +547,13 @@ public Task saveAsync(GameInstanceManifest instanceManifes Files.createDirectories(json.getParent()); JsonUtils.writeToJsonFile(json, savedManifest); - Status currentStatus = status; - currentStatus.instances.put(savedManifest.id(), new InstanceHolder(currentStatus, savedManifest.id(), savedManifest)); + Status newStatus = status.clone(); + newStatus.instances.put(savedManifest.id(), new DefaultGameInstance(newStatus, savedManifest.id(), savedManifest)); // TODO + + // TODO + + status = newStatus; + gameVersions.clear(); return savedManifest; }); @@ -591,14 +600,28 @@ public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) thro return status.resolve(manifest, new HashSet<>()); } + protected DefaultGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { + return new DefaultGameInstance(status, id, manifest); + } + protected static class Status { - private final DefaultGameRepositoryLayout layout; - private final Map instances = new TreeMap<>(); + public final DefaultGameRepository repository; + public final DefaultGameRepositoryLayout layout; + public final Map instances = new TreeMap<>(); - protected Status(DefaultGameRepositoryLayout layout) { + protected Status(DefaultGameRepository repository, DefaultGameRepositoryLayout layout) { + this.repository = repository; this.layout = layout; } + public Status clone() { + Status newStatus = new Status(repository, layout); + for (DefaultGameInstance instance : instances.values()) { + newStatus.instances.put(instance.getId(), instance.withNewStatus(newStatus)); + } + return newStatus; + } + private GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, Set resolvedSoFar) throws NoSuchGameInstanceException { GameInstanceManifest launchManifest; @@ -623,13 +646,13 @@ private GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, launchManifest = (manifest.jar() == null ? manifest.withJar(manifest.id()) : manifest) .withInheritsFrom(null); } else { - InstanceHolder parentInstance = instances.get(manifest.inheritsFrom()); + DefaultGameInstance parentInstance = instances.get(manifest.inheritsFrom()); if (parentInstance == null) { throw new NoSuchGameInstanceException(manifest.inheritsFrom()); } // It is supposed to auto-install a version in getVersion. - GameInstanceManifest.Resolved parentResolved = resolve(parentInstance.manifest, resolvedSoFar); + GameInstanceManifest.Resolved parentResolved = resolve(parentInstance.getManifest(), resolvedSoFar); launchManifest = manifest.merge(parentResolved.launchManifest()); standaloneManifest = addPatches( addPatches(parentResolved.standaloneManifest(), Collections.singleton(manifest.toPatch())), @@ -682,17 +705,4 @@ private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @N } - protected static class InstanceHolder { - protected final Status status; - protected final GameInstanceID id; - protected final GameInstanceManifest manifest; - protected @Nullable GameInstanceManifest.Resolved resolvedManifest; - protected @Nullable GameVersionNumber version; - - protected InstanceHolder(Status status, GameInstanceID id, GameInstanceManifest manifest) { - this.status = status; - this.id = id; - this.manifest = manifest; - } - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java index 23cdb130984..1d79ad1a460 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -21,6 +21,7 @@ import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -73,6 +74,8 @@ public interface GameRepository { /// @return the loaded instance manifests Collection getInstanceManifests(); + @Nullable GameInstance getInstance(GameInstanceID id); + /// Reloads repository state from the backing storage. void refresh(); From d3ad91c77b26b1450b78f39174f6fbe5441275f9 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 3 Aug 2026 21:40:44 +0800 Subject: [PATCH 009/199] refactor: enhance DefaultGameInstance and DefaultGameRepository by utilizing status for manifest resolution --- .../org/jackhuang/hmcl/game/DefaultGameInstance.java | 6 ++++-- .../jackhuang/hmcl/game/DefaultGameRepository.java | 11 +++-------- 2 files changed, 7 insertions(+), 10 deletions(-) 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 d4a1ca7e173..075c9738bdd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -22,10 +22,12 @@ import org.jetbrains.annotations.Nullable; import java.nio.file.Path; +import java.util.HashSet; @NotNullByDefault public class DefaultGameInstance implements GameInstance { + protected final DefaultGameRepository.Status status; protected final DefaultGameRepository repository; protected final DefaultGameRepositoryLayout layout; protected final GameInstanceID id; @@ -37,6 +39,7 @@ protected DefaultGameInstance( DefaultGameRepository.Status status, GameInstanceID id, GameInstanceManifest manifest) { + this.status = status; this.repository = status.repository; this.layout = status.layout; this.id = id; @@ -70,9 +73,8 @@ public GameInstanceManifest getManifest() { @Override public GameInstanceManifest.Resolved getResolvedManifest() { if (resolvedManifest == null) { - resolvedManifest = repository.resolve(manifest); // TODO + resolvedManifest = status.resolve(manifest, new HashSet<>()); } - return resolvedManifest; } 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 63558d6e024..4d8c746cba5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -288,12 +288,7 @@ public GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID throw new NoSuchGameInstanceException(instanceId); } - GameInstanceManifest.Resolved resolvedManifest = instance.resolvedManifest; - if (resolvedManifest == null) { - resolvedManifest = currentStatus.resolve(instance.manifest, new HashSet<>()); - instance.resolvedManifest = resolvedManifest; - } - return resolvedManifest; + return instance.getResolvedManifest(); } @Override @@ -622,8 +617,8 @@ public Status clone() { return newStatus; } - private GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, - Set resolvedSoFar) throws NoSuchGameInstanceException { + GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, + Set resolvedSoFar) throws NoSuchGameInstanceException { GameInstanceManifest launchManifest; GameInstanceManifest standaloneManifest = manifest.isRoot() ? manifest From c1c141f20fbfd99e8b6e70dcd5284ccec99252a1 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 3 Aug 2026 21:43:13 +0800 Subject: [PATCH 010/199] refactor: simplify HMCLGameInstance constructor and update instance creation in HMCLGameRepository --- .../java/org/jackhuang/hmcl/game/HMCLGameInstance.java | 2 +- .../java/org/jackhuang/hmcl/game/HMCLGameRepository.java | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) 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 812fe42fb69..faf113b0473 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -23,7 +23,7 @@ @NotNullByDefault public class HMCLGameInstance extends DefaultGameInstance { - protected HMCLGameInstance(DefaultGameRepository.Status status, DefaultGameRepository repository, DefaultGameRepositoryLayout layout, GameInstanceID id, GameInstanceManifest manifest) { + protected HMCLGameInstance(DefaultGameRepository.Status status, GameInstanceID id, GameInstanceManifest manifest) { super(status, id, manifest); } 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 76f105850db..5a50681a97a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -120,6 +120,11 @@ public HMCLGameRepository(GameDirectory gameDirectory) { gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath())); } + @Override + protected HMCLGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { + return new HMCLGameInstance(status, id, manifest); + } + /// Returns the persistent game directory for this repository. public GameDirectory getGameDirectory() { return gameDirectory; @@ -397,7 +402,7 @@ private InstanceGameSettingsLoadResult loadGameSettingsFile(Path file) { case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: " + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> - LOG.warning("Unsupported instance game settings schema. Expected: " + LOG.warning("Unsupported instance game settings schema. Expected: " + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { } From 6f896bd04627299833e8033f61033c8934596ebd Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 00:50:07 +0800 Subject: [PATCH 011/199] refactor: streamline game instance and repository structure with enhanced settings management --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 462 ++++++++++++++++++ .../hmcl/game/HMCLGameRepository.java | 362 ++++---------- .../hmcl/game/HMCLGameRepositoryLayout.java | 63 +++ .../setting/LegacyGameSettingsMigrator.java | 2 +- .../hmcl/setting/GameDirectoriesTest.java | 2 +- .../hmcl/game/DefaultGameInstance.java | 9 + .../hmcl/game/DefaultGameRepository.java | 33 +- .../jackhuang/hmcl/game/GameRepository.java | 2 +- 8 files changed, 646 insertions(+), 289 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryLayout.java 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 faf113b0473..1431ebd38a5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -17,14 +17,72 @@ */ package org.jackhuang.hmcl.game; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.reflect.TypeToken; +import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.setting.GameSettingsPresetID; +import org.jackhuang.hmcl.setting.LauncherSettings; +import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator; +import org.jackhuang.hmcl.setting.SettingFileUtils; +import org.jackhuang.hmcl.setting.SettingsManager; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonSchema; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.Contract; 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 static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// HMCL-specific game instance that owns the lifecycle of instance-local [GameSettings.Instance]. @NotNullByDefault public class HMCLGameInstance extends DefaultGameInstance { + + /// Loads, caches, and persists the instance-local game settings for this instance. + private GameSettingsController gameSettings; + + /// Creates an instance bound to the given repository status snapshot. + /// + /// @param status the repository status that owns this instance + /// @param id the instance id + /// @param manifest the stored instance manifest protected HMCLGameInstance(DefaultGameRepository.Status status, GameInstanceID id, GameInstanceManifest manifest) { super(status, id, manifest); + this.gameSettings = new GameSettingsController(getRepository(), id); + } + + /// Creates an instance that reuses an existing settings controller. + /// + /// Used when the repository clones a status snapshot so that already-loaded settings and + /// autosave listeners remain attached to the same controller. + /// + /// @param status the repository status that owns this instance + /// @param id the instance id + /// @param manifest the stored instance manifest + /// @param gameSettings the settings controller to adopt + private HMCLGameInstance( + DefaultGameRepository.Status status, + GameInstanceID id, + GameInstanceManifest manifest, + GameSettingsController gameSettings) { + super(status, id, manifest); + this.gameSettings = gameSettings; + } + + @Override + protected HMCLGameInstance withNewStatus(DefaultGameRepository.Status newStatus) { + return new HMCLGameInstance(newStatus, id, manifest, gameSettings); + } + + @Override + protected HMCLGameInstance withManifest(DefaultGameRepository.Status newStatus, GameInstanceManifest manifest) { + return new HMCLGameInstance(newStatus, id, manifest, gameSettings); } @Override @@ -32,39 +90,443 @@ public HMCLGameRepository getRepository() { return (HMCLGameRepository) super.getRepository(); } + @Override + public HMCLGameRepositoryLayout getLayout() { + return (HMCLGameRepositoryLayout) super.getLayout(); + } + + /// Returns the controller that owns this instance's local game settings. + /// + /// @return the settings controller + GameSettingsController gameSettings() { + return gameSettings; + } + + /// Replaces this instance's settings controller. + /// + /// Used when a detached controller created before the instance was indexed should become the + /// authoritative controller for the newly registered instance. + /// + /// @param gameSettings the controller to adopt + void adoptGameSettings(GameSettingsController gameSettings) { + this.gameSettings = gameSettings; + } + + /// Returns the loaded instance-local game settings, loading them on first access. + /// + /// @return the settings, or `null` when no local settings exist after loading + public @Nullable GameSettings.Instance getSettings() { + return gameSettings.get(); + } + + /// Returns the instance-local game settings, creating an empty settings object when absent. + /// + /// @return the settings, or `null` when the settings file is read-only and no settings are loaded + public @Nullable GameSettings.Instance getSettingsOrCreate() { + return gameSettings.getOrCreate(); + } + + /// Creates empty instance-local game settings when none are loaded. + /// + /// @return the settings, or `null` when settings already exist in read-only mode or cannot be created + public @Nullable GameSettings.Instance createSettings() { + return gameSettings.create(); + } + + /// Returns whether the instance-local game settings file cannot be overwritten safely. + /// + /// @return whether the settings are loaded in read-only mode + public boolean isSettingsReadOnly() { + return gameSettings.isReadOnly(); + } + + /// Backs up and overwrites the instance-local game settings file with the currently loaded settings. + public void forceOverwriteSettings() { + gameSettings.forceOverwrite(); + } + + /// Saves the currently loaded instance-local game settings asynchronously when writable. + public void saveSettings() { + gameSettings.save(); + } + + /// Saves the currently loaded instance-local game settings synchronously when writable. + /// + /// @throws IOException if saving the file fails + public void saveSettingsSync() throws IOException { + gameSettings.saveSync(); + } + + /// Initializes this instance with the given settings object. + /// + /// @param setting the settings to install + /// @return the installed settings + public GameSettings.Instance initSettings(GameSettings.Instance setting) { + return gameSettings.init(setting, true); + } + + /// Initializes this instance with the given settings object. + /// + /// @param setting the settings to install + /// @param allowSave whether the settings may be written back to disk + /// @return the installed settings + public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) { + return gameSettings.init(setting, allowSave); + } + + /// Returns a deep copy of the currently loaded settings, or a new settings object that inherits + /// the effective parent preset when no local settings exist. + /// + /// @return a detached copy suitable for installing into another instance + public GameSettings.Instance copySettings() { + return gameSettings.copy(); + } + + /// Owns the load, cache, mutation, and persistence lifecycle of one instance's local game settings. + /// + /// A controller may be attached to an [HMCLGameInstance], or held temporarily by + /// [HMCLGameRepository] for instance IDs that are not yet present in the repository index + /// (for example during new-instance installation). + @NotNullByDefault + static final class GameSettingsController { + private final HMCLGameRepository repository; + private final GameInstanceID instanceId; + + private boolean loaded; + private boolean readOnly; + private GameSettings.@Nullable Instance settings; + + /// Creates a controller for the given repository and instance id. + /// + /// @param repository the owning repository + /// @param instanceId the instance id whose settings file is managed + GameSettingsController(HMCLGameRepository repository, GameInstanceID instanceId) { + this.repository = repository; + this.instanceId = instanceId; + } + + /// Returns the instance id managed by this controller. + /// + /// @return the instance id + GameInstanceID instanceId() { + return instanceId; + } + + /// Returns whether the settings file has already been inspected. + /// + /// @return whether loading has been attempted + boolean isLoaded() { + return loaded; + } + + /// Returns whether the settings file cannot be overwritten safely. + /// + /// @return whether the settings are read-only + boolean isReadOnly() { + ensureLoaded(); + return readOnly; + } + + /// Returns the loaded settings, loading them on first access. + /// + /// @return the settings, or `null` when no local settings exist after loading + @Nullable GameSettings.Instance get() { + ensureLoaded(); + return settings; + } + + /// Returns the settings, creating empty writable settings when absent. + /// + /// @return the settings, or `null` when the settings file is read-only and no settings are loaded + @Nullable GameSettings.Instance getOrCreate() { + GameSettings.Instance setting = get(); + if (setting == null) { + setting = create(); + } + return setting; + } + + /// Creates empty writable settings when none are loaded. + /// + /// @return the settings, or `null` when settings are read-only or already present + @Nullable GameSettings.Instance create() { + ensureLoaded(); + if (readOnly) { + return null; + } + if (settings != null) { + return settings; + } + return init(new GameSettings.Instance(), true); + } + + /// Installs the given settings object as the cached local settings. + /// + /// @param setting the settings to install + /// @param allowSave whether the settings may be written back to disk + /// @return the installed settings + GameSettings.Instance init(GameSettings.Instance setting, boolean allowSave) { + normalizeRunningDirectoryOverride(setting); + setting.setSavable(allowSave); + loaded = true; + settings = setting; + if (allowSave) { + readOnly = false; + setting.addListener(a -> save()); + } else { + readOnly = true; + } + return setting; + } + + /// Backs up and overwrites the settings file with the currently loaded settings. + void forceOverwrite() { + ensureLoaded(); + + GameSettings.Instance setting = settings; + if (setting == null) { + setting = new GameSettings.Instance(); + settings = setting; + loaded = true; + } + + boolean installAutoSave = !setting.isSavable(); + Path file = settingsFile().toAbsolutePath().normalize(); + SettingFileUtils.backupInvalidConfig(file); + setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); + setting.setSavable(true); + setting.setBackupOnNextSave(false); + readOnly = false; + save(); + if (installAutoSave) { + setting.addListener(a -> save()); + } + } + + /// Saves the currently loaded settings asynchronously when writable. + void save() { + if (settings == null || readOnly) { + return; + } + + GameSettings.Instance setting = settings; + Path file = settingsFile().toAbsolutePath().normalize(); + try { + Files.createDirectories(file.getParent()); + } catch (IOException e) { + LOG.warning("Failed to create directory: " + file.getParent(), e); + } + + if (setting.isBackupOnNextSave()) { + setting.setBackupOnNextSave(false); + SettingFileUtils.backupInvalidConfig(file); + } + org.jackhuang.hmcl.util.FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); + } + + /// Saves the currently loaded settings synchronously when writable. + /// + /// @throws IOException if saving the file fails + void saveSync() throws IOException { + if (settings == null || readOnly) { + return; + } + + GameSettings.Instance setting = settings; + Path file = settingsFile().toAbsolutePath().normalize(); + Files.createDirectories(file.getParent()); + if (setting.isBackupOnNextSave()) { + setting.setBackupOnNextSave(false); + SettingFileUtils.backupInvalidConfig(file); + } + FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); + } + + /// Returns a deep copy of the loaded settings, or a new object bound to the effective parent. + /// + /// @return a detached copy of the settings + GameSettings.Instance copy() { + GameSettings.Instance setting = get(); + if (setting != null) { + return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class)); + } + + GameSettings.Instance copied = new GameSettings.Instance(); + copied.parentProperty().setValue( + repository.getEffectiveGameSettings(instanceId).getPreset().idProperty().getValue()); + return copied; + } + + private void ensureLoaded() { + if (!loaded) { + load(); + } + } + + private void load() { + loaded = true; + LoadResult result = loadSettingsFile(settingsFile()); + if (result.setting() != null) { + init(result.setting(), result.allowSave()); + return; + } + if (!result.allowSave()) { + readOnly = true; + return; + } + + @Nullable GameSettingsPresetID legacyParent = repository.getGameDirectory().getLegacyGameSettings(); + if (SettingsManager.getGameSettings(legacyParent) == null) { + legacyParent = null; + } + + LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult = + LegacyGameSettingsMigrator.migrateInstanceGameSettings( + repository, instanceId, legacyParent); + if (migrationResult != null) { + init(migrationResult.setting(), true); + try { + saveSync(); + migrationResult.saveReceipt(); + } catch (IOException e) { + LOG.warning("Failed to save migrated instance game settings for " + instanceId, e); + } + } + } + + private Path settingsFile() { + return repository.getLayout().getInstanceGameSettingsFile(instanceId); + } + + /// Loads a new-format instance game settings file. + private static LoadResult loadSettingsFile(Path file) { + if (!Files.exists(file)) { + return new LoadResult(null, true); + } + + try { + JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class); + if (jsonObject == null) { + LOG.warning("Instance game settings are empty: " + file); + GameSettings.Instance fallback = new GameSettings.Instance(); + return new LoadResult(fallback, true); + } + + JsonSchema.CompatibilityResult schemaResult = + JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA); + switch (schemaResult.status()) { + case MISSING -> LOG.warning("Missing schema in instance game settings: " + file); + case INVALID -> LOG.warning("Invalid schema in instance game settings: " + + file + ", Actual: " + schemaResult.invalidValue()); + case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: " + + file + ", Actual: " + schemaResult.actual()); + case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: " + + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); + case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> + LOG.warning("Unsupported instance game settings schema. Expected: " + + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); + case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { + } + } + if (!schemaResult.readable()) { + GameSettings.Instance fallback = new GameSettings.Instance(); + fallback.setSavable(false); + return new LoadResult(fallback, false); + } + + GameSettings.@Nullable Instance setting = + LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class); + if (setting == null) { + LOG.warning("Instance game settings deserialized to null: " + file); + GameSettings.Instance fallback = new GameSettings.Instance(); + fallback.setBackupOnNextSave(true); + return new LoadResult(fallback, true); + } + if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) { + setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); + } + return new LoadResult(setting, schemaResult.allowSave()); + } catch (JsonParseException ex) { + LOG.warning("Failed to parse game setting " + file, ex); + GameSettings.Instance fallback = new GameSettings.Instance(); + fallback.setBackupOnNextSave(true); + return new LoadResult(fallback, true); + } catch (Exception ex) { + LOG.warning("Failed to load game setting " + file, ex); + return new LoadResult(null, false); + } + } + + /// Keeps old local custom running directories effective under the new source-selection model. + private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) { + if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) { + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); + } + } + + /// Result of loading an instance-specific game settings file. + /// + /// @param setting the loaded instance settings, or `null` when unavailable + /// @param allowSave whether the file may be overwritten + private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) { + } + } + + /// Optional reference to an HMCL game instance and its repository. @NotNullByDefault public static final class Optional { private final HMCLGameRepository repository; private final @Nullable HMCLGameInstance instance; + /// Creates an empty optional bound only to a repository. + /// + /// @param repository the repository public Optional(HMCLGameRepository repository) { this.repository = repository; this.instance = null; } + /// Creates an optional that holds the given instance. + /// + /// @param instance the instance public Optional(HMCLGameInstance instance) { this.repository = instance.getRepository(); this.instance = instance; } + /// Returns the repository associated with this optional. + /// + /// @return the repository public HMCLGameRepository repository() { return repository; } + /// Returns the held instance, if any. + /// + /// @return the instance, or `null` when empty @Contract(pure = true) public @Nullable HMCLGameInstance instance() { return instance; } + /// Returns the held instance id, if any. + /// + /// @return the instance id, or `null` when empty @Contract(pure = true) public @Nullable GameInstanceID instanceId() { return instance != null ? instance.getId() : null; } + /// Returns whether an instance is present. + /// + /// @return whether [#instance()] is non-null public boolean isPresent() { return instance != null; } + /// Returns whether no instance is present. + /// + /// @return whether [#instance()] is null public boolean isEmpty() { return instance == null; } 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 5a50681a97a..5f28bcaa2d3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -17,9 +17,7 @@ */ package org.jackhuang.hmcl.game; -import com.google.gson.JsonObject; import com.google.gson.JsonParseException; -import com.google.gson.reflect.TypeToken; import javafx.beans.binding.Binding; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; @@ -35,22 +33,17 @@ import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.modpack.ModpackProvider; -import org.jackhuang.hmcl.setting.LauncherSettings; import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.setting.DefaultIsolationType; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.GameWindowType; -import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator; import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.setting.ProxyType; -import org.jackhuang.hmcl.setting.SettingFileUtils; import org.jackhuang.hmcl.setting.GameSettingsPresetID; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.gson.JsonSchema; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; @@ -85,29 +78,18 @@ public final class HMCLGameRepository extends DefaultGameRepository { public record InstanceReference(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { } - /// Directory under the instance root that stores HMCL-managed instance metadata. - private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl"; - - /// Directory under the instance metadata directory that stores instance configuration files. - private static final String INSTANCE_CONFIG_DIRECTORY = "config"; - - /// Directory under the instance metadata directory that stores instance state files. - private static final String INSTANCE_STATE_DIRECTORY = "state"; - - /// Current file name for instance-specific game settings. - private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json"; - /// The persistent game directory for this repository. private final GameDirectory gameDirectory; /// The selected instance ID persisted for this repository's game directory. private final ObjectBinding<@Nullable GameInstanceID> selectedInstance; - // instance game settings - private final Map instanceGameSettings = new HashMap<>(); - /// Instance IDs whose local game settings file has already been checked. - private final Set loadedInstanceGameSettings = new HashSet<>(); - private final Set readOnlyInstanceGameSettings = new HashSet<>(); + /// Settings controllers for instance IDs that are not yet present in the repository index. + /// + /// Used during new-instance installation and similar flows that need isolation settings before + /// the instance manifest has been saved and indexed. + private final Map detachedGameSettings = new HashMap<>(); + private final Set beingModpackInstances = new HashSet<>(); public final EventManager onInstanceIconChanged = new EventManager<>(); @@ -120,9 +102,58 @@ public HMCLGameRepository(GameDirectory gameDirectory) { gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath())); } + @Override + protected HMCLGameRepositoryLayout createLayout(Path baseDirectory) { + return new HMCLGameRepositoryLayout(baseDirectory); + } + @Override protected HMCLGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { - return new HMCLGameInstance(status, id, manifest); + HMCLGameInstance instance = new HMCLGameInstance(status, id, manifest); + HMCLGameInstance.GameSettingsController detached = detachedGameSettings.remove(id); + if (detached != null) { + instance.adoptGameSettings(detached); + } + return instance; + } + + @Override + public HMCLGameRepositoryLayout getLayout() { + return (HMCLGameRepositoryLayout) super.getLayout(); + } + + @Override + public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { + return (HMCLGameInstance) super.getInstance(id); + } + + /// Returns the indexed instance for the given id, or `null` when it is not loaded. + /// + /// @param id the instance id + /// @return the instance, or `null` when absent + public @Nullable HMCLGameInstance findInstance(GameInstanceID id) { + try { + return getInstance(id); + } catch (NoSuchGameInstanceException e) { + return null; + } + } + + /// Returns the settings controller for the given instance id. + /// + /// When the instance is already indexed, its own controller is returned. Otherwise a detached + /// controller is created and retained until the instance is registered or the repository is + /// refreshed. + /// + /// @param instanceId the instance id + /// @return the settings controller for the id + private HMCLGameInstance.GameSettingsController gameSettings(GameInstanceID instanceId) { + HMCLGameInstance instance = findInstance(instanceId); + if (instance != null) { + return instance.gameSettings(); + } + return detachedGameSettings.computeIfAbsent( + instanceId, id -> new HMCLGameInstance.GameSettingsController(this, id)); } /// Returns the persistent game directory for this repository. @@ -216,11 +247,8 @@ public Stream getDisplayInstanceManifests() { @Override protected void refreshImpl() { - instanceGameSettings.clear(); - loadedInstanceGameSettings.clear(); - readOnlyInstanceGameSettings.clear(); + detachedGameSettings.clear(); super.refreshImpl(); - getInstanceManifests().stream().map(GameInstanceManifest::id).forEach(this::loadInstanceGameSettings); try { Path file = getBaseDirectory().resolve("launcher_profiles.json"); @@ -253,9 +281,7 @@ public void clean(GameInstanceID instanceId) throws IOException { public boolean removeInstanceFromDisk(GameInstanceID instanceId) { boolean removed = super.removeInstanceFromDisk(instanceId); if (removed) { - instanceGameSettings.remove(instanceId); - loadedInstanceGameSettings.remove(instanceId); - readOnlyInstanceGameSettings.remove(instanceId); + detachedGameSettings.remove(instanceId); beingModpackInstances.remove(instanceId); } return removed; @@ -299,11 +325,12 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea Path srcGameDir = getRunDirectory(srcId); - GameSettings.Instance newGameSettings = copyInstanceGameSettings(srcId); + GameSettings.Instance newGameSettings = gameSettings(srcId).copy(); newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); newGameSettings.runningDirectoryProperty().setValue(""); - initInstanceGameSettings(dstId, newGameSettings); - saveGameSettingsSync(dstId); + HMCLGameInstance.GameSettingsController dstSettings = gameSettings(dstId); + dstSettings.init(newGameSettings, true); + dstSettings.saveSync(); Path dstGameDir = getRunDirectory(dstId); @@ -311,179 +338,30 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null)); } - private GameSettings.Instance copyInstanceGameSettings(GameInstanceID instanceId) { - GameSettings.Instance setting = getInstanceGameSettings(instanceId); - if (setting != null) { - return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class)); - } - - GameSettings.Instance copied = new GameSettings.Instance(); - copied.parentProperty().setValue(getEffectiveGameSettings(instanceId).getPreset().idProperty().getValue()); - return copied; - } - - /// Returns the HMCL-managed metadata directory under the instance root. + /// Creates empty instance-local game settings for an indexed instance when none are loaded. /// - /// This directory stores instance-scoped files owned by HMCL. - public Path getInstanceMetadataDirectory(GameInstanceID instanceId) { - return getLayout().getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY); - } - - /// Returns the HMCL-managed configuration directory under the instance metadata directory. - public Path getInstanceConfigDirectory(GameInstanceID instanceId) { - return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY); - } - - /// Returns the HMCL-managed state directory under the instance metadata directory. - public Path getInstanceStateDirectory(GameInstanceID instanceId) { - return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY); - } - - /// Returns the current local game settings path under the instance configuration directory. - private Path getInstanceGameSettingsFile(GameInstanceID instanceId) { - return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME); - } - - private void loadInstanceGameSettings(GameInstanceID instanceId) { - loadedInstanceGameSettings.add(instanceId); - InstanceGameSettingsLoadResult result = loadGameSettingsFile(getInstanceGameSettingsFile(instanceId)); - if (result.setting() != null) { - initInstanceGameSettings(instanceId, result.setting(), result.allowSave()); - return; - } - if (!result.allowSave()) { - readOnlyInstanceGameSettings.add(instanceId); - return; - } - - @Nullable GameSettingsPresetID legacyParent = gameDirectory.getLegacyGameSettings(); - if (SettingsManager.getGameSettings(legacyParent) == null) { - legacyParent = null; - } - - LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult = - LegacyGameSettingsMigrator.migrateInstanceGameSettings( - this, instanceId, - legacyParent); - if (migrationResult != null) { - initInstanceGameSettings(instanceId, migrationResult.setting()); - try { - saveGameSettingsSync(instanceId); - migrationResult.saveReceipt(); - } catch (IOException e) { - LOG.warning("Failed to save migrated instance game settings for " + instanceId, e); - } - return; - } - } - - /// Loads a new-format instance game settings file. - private InstanceGameSettingsLoadResult loadGameSettingsFile(Path file) { - if (!Files.exists(file)) { - return new InstanceGameSettingsLoadResult(null, true); - } - - try { - JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class); - if (jsonObject == null) { - LOG.warning("Instance game settings are empty: " + file); - GameSettings.Instance fallback = new GameSettings.Instance(); - return new InstanceGameSettingsLoadResult(fallback, true); - } - - JsonSchema.CompatibilityResult schemaResult = - JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA); - switch (schemaResult.status()) { - case MISSING -> LOG.warning("Missing schema in instance game settings: " + file); - case INVALID -> LOG.warning("Invalid schema in instance game settings: " - + file + ", Actual: " + schemaResult.invalidValue()); - case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: " - + file + ", Actual: " + schemaResult.actual()); - case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: " - + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); - case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> - LOG.warning("Unsupported instance game settings schema. Expected: " - + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); - case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { - } - } - if (!schemaResult.readable()) { - GameSettings.Instance fallback = new GameSettings.Instance(); - fallback.setSavable(false); - return new InstanceGameSettingsLoadResult(fallback, false); - } - - GameSettings.Instance setting = - LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class); - if (setting == null) { - LOG.warning("Instance game settings deserialized to null: " + file); - GameSettings.Instance fallback = new GameSettings.Instance(); - fallback.setBackupOnNextSave(true); - return new InstanceGameSettingsLoadResult(fallback, true); - } - if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) { - setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); - } - return new InstanceGameSettingsLoadResult(setting, schemaResult.allowSave()); - } catch (JsonParseException ex) { - LOG.warning("Failed to parse game setting " + file, ex); - GameSettings.Instance fallback = new GameSettings.Instance(); - fallback.setBackupOnNextSave(true); - return new InstanceGameSettingsLoadResult(fallback, true); - } catch (Exception ex) { - LOG.warning("Failed to load game setting " + file, ex); - return new InstanceGameSettingsLoadResult(null, false); - } - } - + /// @param instanceId the instance id + /// @return the settings, or `null` when the instance is missing or settings are read-only public @Nullable GameSettings.Instance createInstanceGameSettings(GameInstanceID instanceId) { if (!hasInstance(instanceId)) { return null; } - if (readOnlyInstanceGameSettings.contains(instanceId)) { - return null; - } - if (instanceGameSettings.containsKey(instanceId)) { - return getInstanceGameSettings(instanceId); - } - - GameSettings.Instance setting = new GameSettings.Instance(); - return initInstanceGameSettings(instanceId, setting); - } - - private GameSettings.Instance initInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting) { - return initInstanceGameSettings(instanceId, setting, true); - } - - private GameSettings.Instance initInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting, boolean allowSave) { - normalizeRunningDirectoryOverride(setting); - setting.setSavable(allowSave); - loadedInstanceGameSettings.add(instanceId); - instanceGameSettings.put(instanceId, setting); - if (allowSave) { - readOnlyInstanceGameSettings.remove(instanceId); - setting.addListener(a -> saveGameSettings(instanceId)); - } else { - readOnlyInstanceGameSettings.add(instanceId); - } - return setting; - } - - /// Keeps old local custom running directories effective under the new source-selection model. - private void normalizeRunningDirectoryOverride(GameSettings.Instance setting) { - if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) { - setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); - } + return gameSettings(instanceId).create(); } + /// Returns the loaded instance-local game settings for the given id. + /// + /// @param instanceId the instance id + /// @return the settings, or `null` when no local settings exist after loading @Nullable public GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) { - if (!loadedInstanceGameSettings.contains(instanceId)) { - loadInstanceGameSettings(instanceId); - } - return instanceGameSettings.get(instanceId); + return gameSettings(instanceId).get(); } + /// Returns the instance-local game settings, creating empty settings when absent. + /// + /// @param instanceId the instance id + /// @return the settings, or `null` when the instance is not indexed and no settings can be created @Nullable public GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) { GameSettings.Instance setting = getInstanceGameSettings(instanceId); @@ -498,39 +376,14 @@ public GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID inst /// @param instanceId the instance ID /// @return whether the instance settings are loaded in read-only mode public boolean isInstanceGameSettingsReadOnly(GameInstanceID instanceId) { - if (!loadedInstanceGameSettings.contains(instanceId)) { - loadInstanceGameSettings(instanceId); - } - - return readOnlyInstanceGameSettings.contains(instanceId); + return gameSettings(instanceId).isReadOnly(); } /// Backs up and overwrites the instance-specific game settings file with the currently loaded settings. /// /// @param instanceId the instance ID public void forceOverwriteInstanceGameSettings(GameInstanceID instanceId) { - if (!loadedInstanceGameSettings.contains(instanceId)) { - loadInstanceGameSettings(instanceId); - } - - GameSettings.Instance setting = instanceGameSettings.get(instanceId); - if (setting == null) { - setting = new GameSettings.Instance(); - instanceGameSettings.put(instanceId, setting); - loadedInstanceGameSettings.add(instanceId); - } - - boolean installAutoSave = !setting.isSavable(); - Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize(); - SettingFileUtils.backupInvalidConfig(file); - setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); - setting.setSavable(true); - setting.setBackupOnNextSave(false); - readOnlyInstanceGameSettings.remove(instanceId); - saveGameSettings(instanceId); - if (installAutoSave) { - setting.addListener(a -> saveGameSettings(instanceId)); - } + gameSettings(instanceId).forceOverwrite(); } /// Returns the explicit parent preset of the instance, falling back to the default preset. @@ -580,16 +433,17 @@ public boolean shouldIsolateNewInstance(boolean modded) { /// Applies default isolation to a new instance before its manifest is saved. public void applyDefaultIsolationSettingForNewInstance(GameInstanceID instanceId, boolean modded) { - if (!shouldIsolateNewInstance(modded) || readOnlyInstanceGameSettings.contains(instanceId)) { + HMCLGameInstance.GameSettingsController settings = gameSettings(instanceId); + if (!shouldIsolateNewInstance(modded) || settings.isReadOnly()) { return; } - GameSettings.Instance setting = getInstanceGameSettings(instanceId); + GameSettings.Instance setting = settings.get(); if (setting == null) { - setting = initInstanceGameSettings(instanceId, new GameSettings.Instance()); + setting = settings.init(new GameSettings.Instance(), true); } if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { - saveGameSettings(instanceId); + settings.save(); } } @@ -684,57 +538,11 @@ else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) } } - public void saveGameSettings(GameInstanceID instanceId) { - if (!instanceGameSettings.containsKey(instanceId) || readOnlyInstanceGameSettings.contains(instanceId)) - return; - GameSettings.Instance setting = instanceGameSettings.get(instanceId); - if (setting == null) { - return; - } - Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize(); - try { - Files.createDirectories(file.getParent()); - } catch (IOException e) { - LOG.warning("Failed to create directory: " + file.getParent(), e); - } - - if (setting.isBackupOnNextSave()) { - setting.setBackupOnNextSave(false); - SettingFileUtils.backupInvalidConfig(file); - } - FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); - } - - /// Saves instance-specific game settings synchronously. + /// Saves instance-specific game settings asynchronously when writable. /// /// @param instanceId the instance ID - /// @throws IOException if saving the file fails - private void saveGameSettingsSync(GameInstanceID instanceId) throws IOException { - if (!instanceGameSettings.containsKey(instanceId) || readOnlyInstanceGameSettings.contains(instanceId)) { - return; - } - - GameSettings.Instance setting = instanceGameSettings.get(instanceId); - if (setting == null) { - return; - } - - Path file = getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize(); - Files.createDirectories(file.getParent()); - if (setting.isBackupOnNextSave()) { - setting.setBackupOnNextSave(false); - SettingFileUtils.backupInvalidConfig(file); - } - FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); - } - - /// Result of loading an instance-specific game settings file. - /// - /// @param setting the loaded instance settings, or `null` when unavailable - /// @param allowSave whether the file may be overwritten - private record InstanceGameSettingsLoadResult( - @Nullable GameSettings.Instance setting, - boolean allowSave) { + public void saveGameSettings(GameInstanceID instanceId) { + gameSettings(instanceId).save(); } public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryLayout.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryLayout.java new file mode 100644 index 00000000000..0bd88cb70b6 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryLayout.java @@ -0,0 +1,63 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jetbrains.annotations.NotNullByDefault; + +import java.nio.file.Path; + +@NotNullByDefault +public final class HMCLGameRepositoryLayout extends DefaultGameRepositoryLayout { + /// Directory under the instance root that stores HMCL-managed instance metadata. + private static final String INSTANCE_METADATA_DIRECTORY = ".hmcl"; + + /// Directory under the instance metadata directory that stores instance configuration files. + private static final String INSTANCE_CONFIG_DIRECTORY = "config"; + + /// Directory under the instance metadata directory that stores instance state files. + private static final String INSTANCE_STATE_DIRECTORY = "state"; + + /// Current file name for instance-specific game settings. + private static final String INSTANCE_GAME_SETTINGS_FILENAME = "instance-game-settings.json"; + + public HMCLGameRepositoryLayout(Path baseDirectory) { + super(baseDirectory); + } + + /// Returns the HMCL-managed metadata directory under the instance root. + /// + /// This directory stores instance-scoped files owned by HMCL. + public Path getInstanceMetadataDirectory(GameInstanceID instanceId) { + return getInstanceRoot(instanceId).resolve(INSTANCE_METADATA_DIRECTORY); + } + + /// Returns the HMCL-managed configuration directory under the instance metadata directory. + public Path getInstanceConfigDirectory(GameInstanceID instanceId) { + return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_CONFIG_DIRECTORY); + } + + /// Returns the HMCL-managed state directory under the instance metadata directory. + public Path getInstanceStateDirectory(GameInstanceID instanceId) { + return getInstanceMetadataDirectory(instanceId).resolve(INSTANCE_STATE_DIRECTORY); + } + + /// Returns the current local game settings path under the instance configuration directory. + public Path getInstanceGameSettingsFile(GameInstanceID instanceId) { + return getInstanceConfigDirectory(instanceId).resolve(INSTANCE_GAME_SETTINGS_FILENAME); + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java index 89f8e6faee9..e72526cf004 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LegacyGameSettingsMigrator.java @@ -137,7 +137,7 @@ public static GameSettings.Preset toPreset(GameSettingsPresetID id, int autoName if (!Files.exists(file)) { return null; } - Path receiptLocation = repository.getInstanceStateDirectory(instanceId) + Path receiptLocation = repository.getLayout().getInstanceStateDirectory(instanceId) .resolve(LEGACY_INSTANCE_SETTINGS_MIGRATION_RECEIPT_FILENAME); if (MigrationReceipt.matches(receiptLocation, file)) { LOG.info("Skipping already migrated legacy version setting " + file); diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java index 58cc71b4e78..f1db2ae278f 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -580,7 +580,7 @@ public void startupMigrationSkipsLegacyInstanceSettingsFile(@TempDir Path tempDi LegacyConfigMigrator.migrateLegacyInstanceGameSettings(localDirectories, presets); - assertFalse(Files.exists(repository.getInstanceConfigDirectory(instanceId) + assertFalse(Files.exists(repository.getLayout().getInstanceConfigDirectory(instanceId) .resolve(LegacyGameSettingsMigrator.INSTANCE_GAME_SETTINGS_FILENAME))); GameSettings.Instance setting = Objects.requireNonNull(repository.getInstanceGameSettings(instanceId)); assertEquals(legacyPresetId, setting.parentProperty().getValue()); 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 075c9738bdd..3f5f199c7bb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -50,6 +50,15 @@ protected DefaultGameInstance withNewStatus(DefaultGameRepository.Status newStat return new DefaultGameInstance(newStatus, id, manifest); } + /// Returns a copy of this instance bound to a new status and stored manifest. + /// + /// @param newStatus the status that will own the copy + /// @param manifest the stored instance manifest + /// @return the updated instance + protected DefaultGameInstance withManifest(DefaultGameRepository.Status newStatus, GameInstanceManifest manifest) { + return new DefaultGameInstance(newStatus, id, manifest); + } + @Override public DefaultGameRepository getRepository() { return repository; 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 4d8c746cba5..cc647dd3fe1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -92,7 +92,15 @@ private static boolean hasClassicVersion(Path baseDirectory) { private final ConcurrentHashMap> gameVersions = new ConcurrentHashMap<>(); public DefaultGameRepository(Path baseDirectory) { - this.status = new Status(this, new DefaultGameRepositoryLayout(baseDirectory)); + this.status = new Status(this, createLayout(baseDirectory)); + } + + /// Creates the repository layout rooted at the given directory. + /// + /// @param baseDirectory the repository base directory + /// @return the layout used by this repository + protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { + return new DefaultGameRepositoryLayout(baseDirectory); } public Path getBaseDirectory() { @@ -100,13 +108,13 @@ public Path getBaseDirectory() { } public void setBaseDirectory(Path baseDirectory) { - this.status = new Status(this, new DefaultGameRepositoryLayout(baseDirectory)); + this.status = new Status(this, createLayout(baseDirectory)); this.loaded = false; this.gameVersions.clear(); } @Override - public GameRepositoryLayout getLayout() { + public DefaultGameRepositoryLayout getLayout() { return status.layout; } @@ -302,8 +310,13 @@ public Collection getInstanceManifests() { } @Override - public @Nullable GameInstance getInstance(GameInstanceID id) { - return null; + public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException{ + @Nullable DefaultGameInstance instance = status.instances.get(id); + if (instance != null) { + return instance; + } else { + throw new NoSuchGameInstanceException(id); + } } public Path getArtifactFile(GameInstanceManifest manifest, Artifact artifact) { @@ -543,10 +556,12 @@ public Task saveAsync(GameInstanceManifest instanceManifes JsonUtils.writeToJsonFile(json, savedManifest); Status newStatus = status.clone(); - newStatus.instances.put(savedManifest.id(), new DefaultGameInstance(newStatus, savedManifest.id(), savedManifest)); // TODO - - // TODO - + DefaultGameInstance existing = newStatus.instances.get(savedManifest.id()); + if (existing != null) { + newStatus.instances.put(savedManifest.id(), existing.withManifest(newStatus, savedManifest)); + } else { + newStatus.instances.put(savedManifest.id(), createInstance(newStatus, savedManifest.id(), savedManifest)); + } status = newStatus; gameVersions.clear(); 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 1d79ad1a460..3a1c29744b5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -74,7 +74,7 @@ public interface GameRepository { /// @return the loaded instance manifests Collection getInstanceManifests(); - @Nullable GameInstance getInstance(GameInstanceID id); + GameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException; /// Reloads repository state from the backing storage. void refresh(); From b1c005681fe3e69506f518d51a5130a000dbfe60 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 00:51:37 +0800 Subject: [PATCH 012/199] refactor: convert DefaultGameInstance, DefaultGameRepository, and DefaultGameRepositoryLayout to abstract classes for improved extensibility --- .../org/jackhuang/hmcl/game/DefaultGameInstance.java | 10 +++------- .../org/jackhuang/hmcl/game/DefaultGameRepository.java | 10 +++------- .../hmcl/game/DefaultGameRepositoryLayout.java | 2 +- 3 files changed, 7 insertions(+), 15 deletions(-) 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 3f5f199c7bb..37ff7d524aa 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -25,7 +25,7 @@ import java.util.HashSet; @NotNullByDefault -public class DefaultGameInstance implements GameInstance { +public abstract class DefaultGameInstance implements GameInstance { protected final DefaultGameRepository.Status status; protected final DefaultGameRepository repository; @@ -46,18 +46,14 @@ protected DefaultGameInstance( this.manifest = manifest; } - protected DefaultGameInstance withNewStatus(DefaultGameRepository.Status newStatus) { - return new DefaultGameInstance(newStatus, id, manifest); - } + protected abstract DefaultGameInstance withNewStatus(DefaultGameRepository.Status newStatus); /// Returns a copy of this instance bound to a new status and stored manifest. /// /// @param newStatus the status that will own the copy /// @param manifest the stored instance manifest /// @return the updated instance - protected DefaultGameInstance withManifest(DefaultGameRepository.Status newStatus, GameInstanceManifest manifest) { - return new DefaultGameInstance(newStatus, id, manifest); - } + protected abstract DefaultGameInstance withManifest(DefaultGameRepository.Status newStatus, GameInstanceManifest manifest); @Override public DefaultGameRepository getRepository() { 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 cc647dd3fe1..9749ea74a89 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -43,7 +43,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; @NotNullByDefault -public class DefaultGameRepository implements GameRepository { +public abstract class DefaultGameRepository implements GameRepository { private static final GameInstanceManifest CLASSIC_MANIFEST = new GameInstanceManifest( new GameInstanceID("Classic"), @@ -99,9 +99,7 @@ public DefaultGameRepository(Path baseDirectory) { /// /// @param baseDirectory the repository base directory /// @return the layout used by this repository - protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { - return new DefaultGameRepositoryLayout(baseDirectory); - } + protected abstract DefaultGameRepositoryLayout createLayout(Path baseDirectory); public Path getBaseDirectory() { return status.layout.getBaseDirectory(); @@ -610,9 +608,7 @@ public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) thro return status.resolve(manifest, new HashSet<>()); } - protected DefaultGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { - return new DefaultGameInstance(status, id, manifest); - } + protected abstract DefaultGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest); protected static class Status { public final DefaultGameRepository repository; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java index 0f1dedbe265..7536ef814b8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java @@ -24,7 +24,7 @@ /// Implements the conventional Minecraft repository directory layout. @NotNullByDefault -public class DefaultGameRepositoryLayout implements GameRepositoryLayout { +public abstract class DefaultGameRepositoryLayout implements GameRepositoryLayout { private final Path baseDirectory; /// Creates a layout rooted at the given directory. From 4a912546818a9b2945c7c30e59f8644c7d9ad465 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 00:57:44 +0800 Subject: [PATCH 013/199] refactor: enhance game instance and repository management with improved settings handling --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 492 +++++++----------- .../hmcl/game/HMCLGameRepository.java | 63 ++- .../hmcl/game/DefaultGameRepository.java | 7 + .../game/DefaultGameRepositoryLayout.java | 2 +- .../hmcl/game/GameInstanceManifestTest.java | 33 +- 5 files changed, 259 insertions(+), 338 deletions(-) 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 1431ebd38a5..11970d7613d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -26,6 +26,7 @@ import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator; import org.jackhuang.hmcl.setting.SettingFileUtils; import org.jackhuang.hmcl.setting.SettingsManager; +import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonSchema; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -44,8 +45,14 @@ @NotNullByDefault public class HMCLGameInstance extends DefaultGameInstance { - /// Loads, caches, and persists the instance-local game settings for this instance. - private GameSettingsController gameSettings; + /// Whether the instance-local game settings file has already been inspected. + private boolean gameSettingsLoaded; + + /// Whether the instance-local game settings file cannot be overwritten safely. + private boolean gameSettingsReadOnly; + + /// Cached instance-local game settings, or `null` when none exist after loading. + private GameSettings.@Nullable Instance gameSettings; /// Creates an instance bound to the given repository status snapshot. /// @@ -54,35 +61,36 @@ public class HMCLGameInstance extends DefaultGameInstance { /// @param manifest the stored instance manifest protected HMCLGameInstance(DefaultGameRepository.Status status, GameInstanceID id, GameInstanceManifest manifest) { super(status, id, manifest); - this.gameSettings = new GameSettingsController(getRepository(), id); } - /// Creates an instance that reuses an existing settings controller. + /// Creates an instance that shares already-loaded game settings with another instance. /// - /// Used when the repository clones a status snapshot so that already-loaded settings and - /// autosave listeners remain attached to the same controller. + /// Used when the repository clones a status snapshot or promotes a pending instance so that + /// cached settings remain available on the new wrapper. /// - /// @param status the repository status that owns this instance - /// @param id the instance id - /// @param manifest the stored instance manifest - /// @param gameSettings the settings controller to adopt + /// @param status the repository status that owns this instance + /// @param id the instance id + /// @param manifest the stored instance manifest + /// @param shareGameSettings the instance whose settings fields should be shared private HMCLGameInstance( DefaultGameRepository.Status status, GameInstanceID id, GameInstanceManifest manifest, - GameSettingsController gameSettings) { + HMCLGameInstance shareGameSettings) { super(status, id, manifest); - this.gameSettings = gameSettings; + this.gameSettingsLoaded = shareGameSettings.gameSettingsLoaded; + this.gameSettingsReadOnly = shareGameSettings.gameSettingsReadOnly; + this.gameSettings = shareGameSettings.gameSettings; } @Override protected HMCLGameInstance withNewStatus(DefaultGameRepository.Status newStatus) { - return new HMCLGameInstance(newStatus, id, manifest, gameSettings); + return new HMCLGameInstance(newStatus, id, manifest, this); } @Override protected HMCLGameInstance withManifest(DefaultGameRepository.Status newStatus, GameInstanceManifest manifest) { - return new HMCLGameInstance(newStatus, id, manifest, gameSettings); + return new HMCLGameInstance(newStatus, id, manifest, this); } @Override @@ -95,66 +103,108 @@ public HMCLGameRepositoryLayout getLayout() { return (HMCLGameRepositoryLayout) super.getLayout(); } - /// Returns the controller that owns this instance's local game settings. - /// - /// @return the settings controller - GameSettingsController gameSettings() { - return gameSettings; - } - - /// Replaces this instance's settings controller. - /// - /// Used when a detached controller created before the instance was indexed should become the - /// authoritative controller for the newly registered instance. - /// - /// @param gameSettings the controller to adopt - void adoptGameSettings(GameSettingsController gameSettings) { - this.gameSettings = gameSettings; - } - /// Returns the loaded instance-local game settings, loading them on first access. /// /// @return the settings, or `null` when no local settings exist after loading public @Nullable GameSettings.Instance getSettings() { - return gameSettings.get(); + ensureGameSettingsLoaded(); + return gameSettings; } /// Returns the instance-local game settings, creating an empty settings object when absent. /// /// @return the settings, or `null` when the settings file is read-only and no settings are loaded public @Nullable GameSettings.Instance getSettingsOrCreate() { - return gameSettings.getOrCreate(); + GameSettings.Instance setting = getSettings(); + if (setting == null) { + setting = createSettings(); + } + return setting; } /// Creates empty instance-local game settings when none are loaded. /// - /// @return the settings, or `null` when settings already exist in read-only mode or cannot be created + /// @return the settings, or `null` when settings are read-only or already present in a non-creatable state public @Nullable GameSettings.Instance createSettings() { - return gameSettings.create(); + ensureGameSettingsLoaded(); + if (gameSettingsReadOnly) { + return null; + } + if (gameSettings != null) { + return gameSettings; + } + return initSettings(new GameSettings.Instance(), true); } /// Returns whether the instance-local game settings file cannot be overwritten safely. /// /// @return whether the settings are loaded in read-only mode public boolean isSettingsReadOnly() { - return gameSettings.isReadOnly(); + ensureGameSettingsLoaded(); + return gameSettingsReadOnly; } /// Backs up and overwrites the instance-local game settings file with the currently loaded settings. public void forceOverwriteSettings() { - gameSettings.forceOverwrite(); + ensureGameSettingsLoaded(); + + GameSettings.Instance setting = gameSettings; + if (setting == null) { + setting = new GameSettings.Instance(); + gameSettings = setting; + gameSettingsLoaded = true; + } + + boolean installAutoSave = !setting.isSavable(); + Path file = getGameSettingsFile().toAbsolutePath().normalize(); + SettingFileUtils.backupInvalidConfig(file); + setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); + setting.setSavable(true); + setting.setBackupOnNextSave(false); + gameSettingsReadOnly = false; + saveSettings(); + if (installAutoSave) { + setting.addListener(a -> saveSettings()); + } } /// Saves the currently loaded instance-local game settings asynchronously when writable. public void saveSettings() { - gameSettings.save(); + if (gameSettings == null || gameSettingsReadOnly) { + return; + } + + GameSettings.Instance setting = gameSettings; + Path file = getGameSettingsFile().toAbsolutePath().normalize(); + try { + Files.createDirectories(file.getParent()); + } catch (IOException e) { + LOG.warning("Failed to create directory: " + file.getParent(), e); + } + + if (setting.isBackupOnNextSave()) { + setting.setBackupOnNextSave(false); + SettingFileUtils.backupInvalidConfig(file); + } + FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); } /// Saves the currently loaded instance-local game settings synchronously when writable. /// /// @throws IOException if saving the file fails public void saveSettingsSync() throws IOException { - gameSettings.saveSync(); + if (gameSettings == null || gameSettingsReadOnly) { + return; + } + + GameSettings.Instance setting = gameSettings; + Path file = getGameSettingsFile().toAbsolutePath().normalize(); + Files.createDirectories(file.getParent()); + if (setting.isBackupOnNextSave()) { + setting.setBackupOnNextSave(false); + SettingFileUtils.backupInvalidConfig(file); + } + FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); } /// Initializes this instance with the given settings object. @@ -162,7 +212,7 @@ public void saveSettingsSync() throws IOException { /// @param setting the settings to install /// @return the installed settings public GameSettings.Instance initSettings(GameSettings.Instance setting) { - return gameSettings.init(setting, true); + return initSettings(setting, true); } /// Initializes this instance with the given settings object. @@ -171,7 +221,17 @@ public GameSettings.Instance initSettings(GameSettings.Instance setting) { /// @param allowSave whether the settings may be written back to disk /// @return the installed settings public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean allowSave) { - return gameSettings.init(setting, allowSave); + normalizeRunningDirectoryOverride(setting); + setting.setSavable(allowSave); + gameSettingsLoaded = true; + gameSettings = setting; + if (allowSave) { + gameSettingsReadOnly = false; + setting.addListener(a -> saveSettings()); + } else { + gameSettingsReadOnly = true; + } + return setting; } /// Returns a deep copy of the currently loaded settings, or a new settings object that inherits @@ -179,299 +239,131 @@ public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean /// /// @return a detached copy suitable for installing into another instance public GameSettings.Instance copySettings() { - return gameSettings.copy(); - } - - /// Owns the load, cache, mutation, and persistence lifecycle of one instance's local game settings. - /// - /// A controller may be attached to an [HMCLGameInstance], or held temporarily by - /// [HMCLGameRepository] for instance IDs that are not yet present in the repository index - /// (for example during new-instance installation). - @NotNullByDefault - static final class GameSettingsController { - private final HMCLGameRepository repository; - private final GameInstanceID instanceId; - - private boolean loaded; - private boolean readOnly; - private GameSettings.@Nullable Instance settings; - - /// Creates a controller for the given repository and instance id. - /// - /// @param repository the owning repository - /// @param instanceId the instance id whose settings file is managed - GameSettingsController(HMCLGameRepository repository, GameInstanceID instanceId) { - this.repository = repository; - this.instanceId = instanceId; - } - - /// Returns the instance id managed by this controller. - /// - /// @return the instance id - GameInstanceID instanceId() { - return instanceId; - } - - /// Returns whether the settings file has already been inspected. - /// - /// @return whether loading has been attempted - boolean isLoaded() { - return loaded; - } - - /// Returns whether the settings file cannot be overwritten safely. - /// - /// @return whether the settings are read-only - boolean isReadOnly() { - ensureLoaded(); - return readOnly; + GameSettings.Instance setting = getSettings(); + if (setting != null) { + return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class)); } - /// Returns the loaded settings, loading them on first access. - /// - /// @return the settings, or `null` when no local settings exist after loading - @Nullable GameSettings.Instance get() { - ensureLoaded(); - return settings; - } + GameSettings.Instance copied = new GameSettings.Instance(); + copied.parentProperty().setValue( + getRepository().getEffectiveGameSettings(id).getPreset().idProperty().getValue()); + return copied; + } - /// Returns the settings, creating empty writable settings when absent. - /// - /// @return the settings, or `null` when the settings file is read-only and no settings are loaded - @Nullable GameSettings.Instance getOrCreate() { - GameSettings.Instance setting = get(); - if (setting == null) { - setting = create(); - } - return setting; + private void ensureGameSettingsLoaded() { + if (!gameSettingsLoaded) { + loadGameSettings(); } + } - /// Creates empty writable settings when none are loaded. - /// - /// @return the settings, or `null` when settings are read-only or already present - @Nullable GameSettings.Instance create() { - ensureLoaded(); - if (readOnly) { - return null; - } - if (settings != null) { - return settings; - } - return init(new GameSettings.Instance(), true); + private void loadGameSettings() { + gameSettingsLoaded = true; + LoadResult result = loadGameSettingsFile(getGameSettingsFile()); + if (result.setting() != null) { + initSettings(result.setting(), result.allowSave()); + return; } - - /// Installs the given settings object as the cached local settings. - /// - /// @param setting the settings to install - /// @param allowSave whether the settings may be written back to disk - /// @return the installed settings - GameSettings.Instance init(GameSettings.Instance setting, boolean allowSave) { - normalizeRunningDirectoryOverride(setting); - setting.setSavable(allowSave); - loaded = true; - settings = setting; - if (allowSave) { - readOnly = false; - setting.addListener(a -> save()); - } else { - readOnly = true; - } - return setting; + if (!result.allowSave()) { + gameSettingsReadOnly = true; + return; } - /// Backs up and overwrites the settings file with the currently loaded settings. - void forceOverwrite() { - ensureLoaded(); - - GameSettings.Instance setting = settings; - if (setting == null) { - setting = new GameSettings.Instance(); - settings = setting; - loaded = true; - } - - boolean installAutoSave = !setting.isSavable(); - Path file = settingsFile().toAbsolutePath().normalize(); - SettingFileUtils.backupInvalidConfig(file); - setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); - setting.setSavable(true); - setting.setBackupOnNextSave(false); - readOnly = false; - save(); - if (installAutoSave) { - setting.addListener(a -> save()); - } + @Nullable GameSettingsPresetID legacyParent = getRepository().getGameDirectory().getLegacyGameSettings(); + if (SettingsManager.getGameSettings(legacyParent) == null) { + legacyParent = null; } - /// Saves the currently loaded settings asynchronously when writable. - void save() { - if (settings == null || readOnly) { - return; - } - - GameSettings.Instance setting = settings; - Path file = settingsFile().toAbsolutePath().normalize(); + LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult = + LegacyGameSettingsMigrator.migrateInstanceGameSettings( + getRepository(), id, legacyParent); + if (migrationResult != null) { + initSettings(migrationResult.setting(), true); try { - Files.createDirectories(file.getParent()); + saveSettingsSync(); + migrationResult.saveReceipt(); } catch (IOException e) { - LOG.warning("Failed to create directory: " + file.getParent(), e); + LOG.warning("Failed to save migrated instance game settings for " + id, e); } - - if (setting.isBackupOnNextSave()) { - setting.setBackupOnNextSave(false); - SettingFileUtils.backupInvalidConfig(file); - } - org.jackhuang.hmcl.util.FileSaver.save(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); - } - - /// Saves the currently loaded settings synchronously when writable. - /// - /// @throws IOException if saving the file fails - void saveSync() throws IOException { - if (settings == null || readOnly) { - return; - } - - GameSettings.Instance setting = settings; - Path file = settingsFile().toAbsolutePath().normalize(); - Files.createDirectories(file.getParent()); - if (setting.isBackupOnNextSave()) { - setting.setBackupOnNextSave(false); - SettingFileUtils.backupInvalidConfig(file); - } - FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); } + } - /// Returns a deep copy of the loaded settings, or a new object bound to the effective parent. - /// - /// @return a detached copy of the settings - GameSettings.Instance copy() { - GameSettings.Instance setting = get(); - if (setting != null) { - return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class)); - } - - GameSettings.Instance copied = new GameSettings.Instance(); - copied.parentProperty().setValue( - repository.getEffectiveGameSettings(instanceId).getPreset().idProperty().getValue()); - return copied; - } + private Path getGameSettingsFile() { + return getLayout().getInstanceGameSettingsFile(id); + } - private void ensureLoaded() { - if (!loaded) { - load(); - } + /// Loads a new-format instance game settings file. + private static LoadResult loadGameSettingsFile(Path file) { + if (!Files.exists(file)) { + return new LoadResult(null, true); } - private void load() { - loaded = true; - LoadResult result = loadSettingsFile(settingsFile()); - if (result.setting() != null) { - init(result.setting(), result.allowSave()); - return; - } - if (!result.allowSave()) { - readOnly = true; - return; - } - - @Nullable GameSettingsPresetID legacyParent = repository.getGameDirectory().getLegacyGameSettings(); - if (SettingsManager.getGameSettings(legacyParent) == null) { - legacyParent = null; + try { + JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class); + if (jsonObject == null) { + LOG.warning("Instance game settings are empty: " + file); + GameSettings.Instance fallback = new GameSettings.Instance(); + return new LoadResult(fallback, true); } - LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult = - LegacyGameSettingsMigrator.migrateInstanceGameSettings( - repository, instanceId, legacyParent); - if (migrationResult != null) { - init(migrationResult.setting(), true); - try { - saveSync(); - migrationResult.saveReceipt(); - } catch (IOException e) { - LOG.warning("Failed to save migrated instance game settings for " + instanceId, e); + JsonSchema.CompatibilityResult schemaResult = + JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA); + switch (schemaResult.status()) { + case MISSING -> LOG.warning("Missing schema in instance game settings: " + file); + case INVALID -> LOG.warning("Invalid schema in instance game settings: " + + file + ", Actual: " + schemaResult.invalidValue()); + case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: " + + file + ", Actual: " + schemaResult.actual()); + case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: " + + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); + case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> + LOG.warning("Unsupported instance game settings schema. Expected: " + + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); + case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { } } - } - - private Path settingsFile() { - return repository.getLayout().getInstanceGameSettingsFile(instanceId); - } - - /// Loads a new-format instance game settings file. - private static LoadResult loadSettingsFile(Path file) { - if (!Files.exists(file)) { - return new LoadResult(null, true); + if (!schemaResult.readable()) { + GameSettings.Instance fallback = new GameSettings.Instance(); + fallback.setSavable(false); + return new LoadResult(fallback, false); } - try { - JsonObject jsonObject = JsonUtils.fromJsonFile(LauncherSettings.SETTINGS_GSON, file, JsonObject.class); - if (jsonObject == null) { - LOG.warning("Instance game settings are empty: " + file); - GameSettings.Instance fallback = new GameSettings.Instance(); - return new LoadResult(fallback, true); - } - - JsonSchema.CompatibilityResult schemaResult = - JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA); - switch (schemaResult.status()) { - case MISSING -> LOG.warning("Missing schema in instance game settings: " + file); - case INVALID -> LOG.warning("Invalid schema in instance game settings: " - + file + ", Actual: " + schemaResult.invalidValue()); - case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: " - + file + ", Actual: " + schemaResult.actual()); - case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: " - + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); - case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> - LOG.warning("Unsupported instance game settings schema. Expected: " - + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); - case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { - } - } - if (!schemaResult.readable()) { - GameSettings.Instance fallback = new GameSettings.Instance(); - fallback.setSavable(false); - return new LoadResult(fallback, false); - } - - GameSettings.@Nullable Instance setting = - LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class); - if (setting == null) { - LOG.warning("Instance game settings deserialized to null: " + file); - GameSettings.Instance fallback = new GameSettings.Instance(); - fallback.setBackupOnNextSave(true); - return new LoadResult(fallback, true); - } - if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) { - setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); - } - return new LoadResult(setting, schemaResult.allowSave()); - } catch (JsonParseException ex) { - LOG.warning("Failed to parse game setting " + file, ex); + GameSettings.@Nullable Instance setting = + LauncherSettings.SETTINGS_GSON.fromJson(jsonObject, GameSettings.Instance.class); + if (setting == null) { + LOG.warning("Instance game settings deserialized to null: " + file); GameSettings.Instance fallback = new GameSettings.Instance(); fallback.setBackupOnNextSave(true); return new LoadResult(fallback, true); - } catch (Exception ex) { - LOG.warning("Failed to load game setting " + file, ex); - return new LoadResult(null, false); } - } - - /// Keeps old local custom running directories effective under the new source-selection model. - private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) { - if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) { - setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); + if (!schemaResult.preserveSchema() && !GameSettings.Instance.CURRENT_SCHEMA.equals(setting.getSchema())) { + setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); } + return new LoadResult(setting, schemaResult.allowSave()); + } catch (JsonParseException ex) { + LOG.warning("Failed to parse game setting " + file, ex); + GameSettings.Instance fallback = new GameSettings.Instance(); + fallback.setBackupOnNextSave(true); + return new LoadResult(fallback, true); + } catch (Exception ex) { + LOG.warning("Failed to load game setting " + file, ex); + return new LoadResult(null, false); } + } - /// Result of loading an instance-specific game settings file. - /// - /// @param setting the loaded instance settings, or `null` when unavailable - /// @param allowSave whether the file may be overwritten - private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) { + /// Keeps old local custom running directories effective under the new source-selection model. + private static void normalizeRunningDirectoryOverride(GameSettings.Instance setting) { + if (StringUtils.isNotBlank(setting.runningDirectoryProperty().getValue())) { + setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); } } + /// Result of loading an instance-specific game settings file. + /// + /// @param setting the loaded instance settings, or `null` when unavailable + /// @param allowSave whether the file may be overwritten + private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) { + } + /// Optional reference to an HMCL game instance and its repository. @NotNullByDefault public static final class Optional { 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 5f28bcaa2d3..862811faa30 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -84,11 +84,11 @@ public record InstanceReference(HMCLGameRepository repository, @Nullable GameIns /// The selected instance ID persisted for this repository's game directory. private final ObjectBinding<@Nullable GameInstanceID> selectedInstance; - /// Settings controllers for instance IDs that are not yet present in the repository index. + /// Instances that are not yet present in the repository index. /// /// Used during new-instance installation and similar flows that need isolation settings before /// the instance manifest has been saved and indexed. - private final Map detachedGameSettings = new HashMap<>(); + private final Map pendingInstances = new HashMap<>(); private final Set beingModpackInstances = new HashSet<>(); @@ -109,12 +109,11 @@ protected HMCLGameRepositoryLayout createLayout(Path baseDirectory) { @Override protected HMCLGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { - HMCLGameInstance instance = new HMCLGameInstance(status, id, manifest); - HMCLGameInstance.GameSettingsController detached = detachedGameSettings.remove(id); - if (detached != null) { - instance.adoptGameSettings(detached); + HMCLGameInstance pending = pendingInstances.remove(id); + if (pending != null) { + return pending.withManifest(status, manifest); } - return instance; + return new HMCLGameInstance(status, id, manifest); } @Override @@ -139,21 +138,21 @@ public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstance } } - /// Returns the settings controller for the given instance id. + /// Returns the instance that owns local game settings for the given id. /// - /// When the instance is already indexed, its own controller is returned. Otherwise a detached - /// controller is created and retained until the instance is registered or the repository is - /// refreshed. + /// When the instance is already indexed, that instance is returned. Otherwise a pending + /// [HMCLGameInstance] is created and retained until the instance is registered or the + /// repository is refreshed. /// /// @param instanceId the instance id - /// @return the settings controller for the id - private HMCLGameInstance.GameSettingsController gameSettings(GameInstanceID instanceId) { + /// @return the instance used to manage settings for the id + private HMCLGameInstance resolveInstance(GameInstanceID instanceId) { HMCLGameInstance instance = findInstance(instanceId); if (instance != null) { - return instance.gameSettings(); + return instance; } - return detachedGameSettings.computeIfAbsent( - instanceId, id -> new HMCLGameInstance.GameSettingsController(this, id)); + return pendingInstances.computeIfAbsent( + instanceId, id -> new HMCLGameInstance(currentStatus(), id, new GameInstanceManifest(id))); } /// Returns the persistent game directory for this repository. @@ -247,7 +246,7 @@ public Stream getDisplayInstanceManifests() { @Override protected void refreshImpl() { - detachedGameSettings.clear(); + pendingInstances.clear(); super.refreshImpl(); try { @@ -281,7 +280,7 @@ public void clean(GameInstanceID instanceId) throws IOException { public boolean removeInstanceFromDisk(GameInstanceID instanceId) { boolean removed = super.removeInstanceFromDisk(instanceId); if (removed) { - detachedGameSettings.remove(instanceId); + pendingInstances.remove(instanceId); beingModpackInstances.remove(instanceId); } return removed; @@ -325,12 +324,12 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea Path srcGameDir = getRunDirectory(srcId); - GameSettings.Instance newGameSettings = gameSettings(srcId).copy(); + GameSettings.Instance newGameSettings = resolveInstance(srcId).copySettings(); newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); newGameSettings.runningDirectoryProperty().setValue(""); - HMCLGameInstance.GameSettingsController dstSettings = gameSettings(dstId); - dstSettings.init(newGameSettings, true); - dstSettings.saveSync(); + HMCLGameInstance dstInstance = resolveInstance(dstId); + dstInstance.initSettings(newGameSettings, true); + dstInstance.saveSettingsSync(); Path dstGameDir = getRunDirectory(dstId); @@ -346,7 +345,7 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea if (!hasInstance(instanceId)) { return null; } - return gameSettings(instanceId).create(); + return resolveInstance(instanceId).createSettings(); } /// Returns the loaded instance-local game settings for the given id. @@ -355,7 +354,7 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea /// @return the settings, or `null` when no local settings exist after loading @Nullable public GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) { - return gameSettings(instanceId).get(); + return resolveInstance(instanceId).getSettings(); } /// Returns the instance-local game settings, creating empty settings when absent. @@ -376,14 +375,14 @@ public GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID inst /// @param instanceId the instance ID /// @return whether the instance settings are loaded in read-only mode public boolean isInstanceGameSettingsReadOnly(GameInstanceID instanceId) { - return gameSettings(instanceId).isReadOnly(); + return resolveInstance(instanceId).isSettingsReadOnly(); } /// Backs up and overwrites the instance-specific game settings file with the currently loaded settings. /// /// @param instanceId the instance ID public void forceOverwriteInstanceGameSettings(GameInstanceID instanceId) { - gameSettings(instanceId).forceOverwrite(); + resolveInstance(instanceId).forceOverwriteSettings(); } /// Returns the explicit parent preset of the instance, falling back to the default preset. @@ -433,17 +432,17 @@ public boolean shouldIsolateNewInstance(boolean modded) { /// Applies default isolation to a new instance before its manifest is saved. public void applyDefaultIsolationSettingForNewInstance(GameInstanceID instanceId, boolean modded) { - HMCLGameInstance.GameSettingsController settings = gameSettings(instanceId); - if (!shouldIsolateNewInstance(modded) || settings.isReadOnly()) { + HMCLGameInstance instance = resolveInstance(instanceId); + if (!shouldIsolateNewInstance(modded) || instance.isSettingsReadOnly()) { return; } - GameSettings.Instance setting = settings.get(); + GameSettings.Instance setting = instance.getSettings(); if (setting == null) { - setting = settings.init(new GameSettings.Instance(), true); + setting = instance.initSettings(new GameSettings.Instance(), true); } if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { - settings.save(); + instance.saveSettings(); } } @@ -542,7 +541,7 @@ else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) /// /// @param instanceId the instance ID public void saveGameSettings(GameInstanceID instanceId) { - gameSettings(instanceId).save(); + resolveInstance(instanceId).saveSettings(); } public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { 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 9749ea74a89..69ef04c9efe 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -111,6 +111,13 @@ public void setBaseDirectory(Path baseDirectory) { this.gameVersions.clear(); } + /// Returns the current repository status snapshot. + /// + /// @return the current status + protected Status currentStatus() { + return status; + } + @Override public DefaultGameRepositoryLayout getLayout() { return status.layout; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java index 7536ef814b8..0f1dedbe265 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java @@ -24,7 +24,7 @@ /// Implements the conventional Minecraft repository directory layout. @NotNullByDefault -public abstract class DefaultGameRepositoryLayout implements GameRepositoryLayout { +public class DefaultGameRepositoryLayout implements GameRepositoryLayout { private final Path baseDirectory; /// Creates a layout rooted at the given directory. diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java index f42b58d5ce6..245520236de 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java @@ -27,10 +27,7 @@ import java.util.Map; import java.util.Objects; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.*; /// Tests for game instance manifest parsing and resolution behavior. @NotNullByDefault @@ -57,7 +54,33 @@ public void testRootManifestWithPatchesUsesPatchView() throws NoSuchGameInstance false, List.of(patch("patch", null))); - GameInstanceManifest.Resolved resolved = new DefaultGameRepository(Path.of(".")).resolve(manifest); + GameInstanceManifest.Resolved resolved = new DefaultGameRepository(Path.of(".")) { + @Override + protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { + return new DefaultGameRepositoryLayout(baseDirectory); + } + + @Override + protected DefaultGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { + final class MyGameInstance extends DefaultGameInstance { + MyGameInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { + super(status, id, manifest); + } + + @Override + protected DefaultGameInstance withNewStatus(Status newStatus) { + return new MyGameInstance(newStatus, id, manifest); + } + + @Override + protected DefaultGameInstance withManifest(Status newStatus, GameInstanceManifest manifest) { + return new MyGameInstance(newStatus, id, manifest); + } + } + + return new MyGameInstance(status, id, manifest); + } + }.resolve(manifest); assertNull(resolved.launchManifest().mainClass()); assertNull(resolved.launchManifest().patches()); From 942600a17e12faee320870623752ad9322bcc025 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 01:05:12 +0800 Subject: [PATCH 014/199] Lift layout-agnostic repository path concepts to GameRepository Assisted-by: grok-build:grok-4.5 --- .../hmcl/game/DefaultGameInstance.java | 2 +- .../hmcl/game/DefaultGameRepository.java | 38 ++--------- .../game/DefaultGameRepositoryLayout.java | 30 ++++++-- .../jackhuang/hmcl/game/GameRepository.java | 68 +++++++++++++++++-- .../hmcl/game/GameRepositoryLayout.java | 38 ++++++----- 5 files changed, 113 insertions(+), 63 deletions(-) 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 37ff7d524aa..97c1e32af5a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -103,6 +103,6 @@ public Path getInstanceJarFile() { @Override public Path getRunDirectory() { - return layout.getBaseDirectory(); + return getRepository().getRunDirectory(id); } } 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 69ef04c9efe..62b227ba4c0 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.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -101,10 +100,6 @@ public DefaultGameRepository(Path baseDirectory) { /// @return the layout used by this repository protected abstract DefaultGameRepositoryLayout createLayout(Path baseDirectory); - public Path getBaseDirectory() { - return status.layout.getBaseDirectory(); - } - public void setBaseDirectory(Path baseDirectory) { this.status = new Status(this, createLayout(baseDirectory)); this.loaded = false; @@ -451,21 +446,10 @@ public Optional getGameVersion(GameInstanceManifest manifest) { } } - @Override - public Path getNativeDirectory(GameInstanceID instanceId, Platform platform) { - return getLayout().getInstanceRoot(instanceId).resolve("natives-" + platform); - } - - @Override - public Path getModsDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("mods"); - } - - @Override - public Path getResourcePackDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("resourcepacks"); - } - + /// Returns the official version manifest file for an instance. + /// + /// @param instanceId the instance id + /// @return the path `versions//.json` below the base directory public Path getInstanceJson(GameInstanceID instanceId) { return getLayout().getInstanceJson(instanceId); } @@ -575,7 +559,7 @@ public Task saveAsync(GameInstanceManifest instanceManifes } public Path getModpackConfiguration(GameInstanceID instanceId) { - return getLayout().getInstanceRoot(instanceId).resolve("modpack.json"); + return getInstanceRoot(instanceId).resolve("modpack.json"); } @Nullable @@ -590,18 +574,6 @@ public boolean isModpack(GameInstanceID instanceId) { return Files.exists(getModpackConfiguration(instanceId)); } - public Path getSavesDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("saves"); - } - - public Path getBackupsDirectory(GameInstanceID instanceID) { - return getRunDirectory(instanceID).resolve("backups"); - } - - public Path getSchematicsDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("schematics"); - } - public ModManager getModManager(GameInstanceID instanceId) { return new ModManager(this, instanceId); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java index 0f1dedbe265..c838ee09ad4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java @@ -22,7 +22,11 @@ import java.nio.file.Path; import java.util.Objects; -/// Implements the conventional Minecraft repository directory layout. +/// Implements the conventional official Minecraft launcher repository directory layout. +/// +/// Instance definitions are stored as `versions//.json`, client jars as +/// `versions//.jar`, with shared `libraries/` and `assets/` directories under the base +/// directory. @NotNullByDefault public class DefaultGameRepositoryLayout implements GameRepositoryLayout { private final Path baseDirectory; @@ -36,29 +40,39 @@ public DefaultGameRepositoryLayout(Path baseDirectory) { this.baseDirectory = Objects.requireNonNull(baseDirectory); } + /// {@inheritDoc} + @Override public Path getBaseDirectory() { return baseDirectory; } /// {@inheritDoc} + /// + /// Official layout path: `versions//` below the base directory. @Override public Path getInstanceRoot(GameInstanceID instanceId) { return getBaseDirectory().resolve("versions").resolve(instanceId.id()); } - /// {@inheritDoc} - @Override + /// Returns the official version manifest file for an instance. + /// + /// @param instanceId the instance ID + /// @return the path `versions//.json` below the base directory public Path getInstanceJson(GameInstanceID instanceId) { return getInstanceRoot(instanceId).resolve(instanceId.id() + ".json"); } - /// {@inheritDoc} - @Override + /// Returns the conventional client jar file for an instance under the official layout. + /// + /// @param instanceId the instance ID + /// @return the path `versions//.jar` below the base directory public Path getInstanceJarFile(GameInstanceID instanceId) { return getInstanceRoot(instanceId).resolve(instanceId.id() + ".jar"); } /// {@inheritDoc} + /// + /// Official layout path: `libraries/` below the base directory. @Override public Path getLibrariesDirectory() { return getBaseDirectory().resolve("libraries"); @@ -79,6 +93,8 @@ public Path getLibraryFile(GameInstanceID owner, Library library) { } /// {@inheritDoc} + /// + /// Official layout path: `assets/` below the base directory. @Override public Path getAssetDirectory() { return getBaseDirectory().resolve("assets"); @@ -98,8 +114,8 @@ public Path getAssetObject(AssetObject object) { /// {@inheritDoc} /// - /// The conventional layout stores logging configurations in a shared directory, so - /// `assetId` does not alter the returned path. + /// The official layout stores logging configurations in a shared directory, so `assetId` does + /// not alter the returned path. @Override public Path getLoggingObject(String assetId, LoggingInfo loggingInfo) { return getAssetDirectory().resolve("log_configs").resolve(loggingInfo.file().getId()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java index 3a1c29744b5..dde21aaf6e3 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.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -35,10 +34,24 @@ /// /// Implementations are responsible for loading instance manifests, resolving inheritance and patches, /// locating instance-owned files, and exposing helper paths used by launch, download, and maintenance code. +/// +/// Path helpers that only forward to [GameRepositoryLayout] describe concepts shared by multiple +/// repository layouts (official and MultiMC-family layouts alike). Layout-specific storage details +/// remain on concrete layout types such as [DefaultGameRepositoryLayout]. @NotNullByDefault public interface GameRepository { + /// Returns the filesystem layout used by this repository. + /// + /// @return the repository layout GameRepositoryLayout getLayout(); + /// Returns the repository base directory. + /// + /// @return the base directory from [#getLayout()] + default Path getBaseDirectory() { + return getLayout().getBaseDirectory(); + } + /// Resolves inheritance into launch and standalone manifest views. /// /// @param manifest the manifest to resolve @@ -74,6 +87,11 @@ public interface GameRepository { /// @return the loaded instance manifests Collection getInstanceManifests(); + /// Returns the indexed game instance for the given id. + /// + /// @param id the instance id + /// @return the game instance + /// @throws NoSuchGameInstanceException if the instance is not loaded in this repository GameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException; /// Reloads repository state from the backing storage. @@ -86,6 +104,14 @@ default Task refreshAsync() { return Task.runAsync(this::refresh); } + /// Returns the directory containing the files owned by an instance. + /// + /// @param instanceId the instance id + /// @return the instance root directory + default Path getInstanceRoot(GameInstanceID instanceId) { + return getLayout().getInstanceRoot(instanceId); + } + /// Returns the working directory used when launching an instance. /// /// @param instanceId the instance id @@ -97,19 +123,49 @@ default Task refreshAsync() { /// @param instanceId the instance id /// @param platform the target platform /// @return the native library directory - Path getNativeDirectory(GameInstanceID instanceId, Platform platform); + default Path getNativeDirectory(GameInstanceID instanceId, Platform platform) { + return getInstanceRoot(instanceId).resolve("natives-" + platform); + } /// Returns the mods directory for an instance. /// /// @param instanceId the instance id - /// @return the mods directory - Path getModsDirectory(GameInstanceID instanceId); + /// @return the mods directory below the run directory + default Path getModsDirectory(GameInstanceID instanceId) { + return getRunDirectory(instanceId).resolve("mods"); + } /// Returns the resource pack directory for an instance. /// /// @param instanceId the instance id - /// @return the resource pack directory - Path getResourcePackDirectory(GameInstanceID instanceId); + /// @return the resource pack directory below the run directory + default Path getResourcePackDirectory(GameInstanceID instanceId) { + return getRunDirectory(instanceId).resolve("resourcepacks"); + } + + /// Returns the saves directory for an instance. + /// + /// @param instanceId the instance id + /// @return the saves directory below the run directory + default Path getSavesDirectory(GameInstanceID instanceId) { + return getRunDirectory(instanceId).resolve("saves"); + } + + /// Returns the world backups directory for an instance. + /// + /// @param instanceId the instance id + /// @return the backups directory below the run directory + default Path getBackupsDirectory(GameInstanceID instanceId) { + return getRunDirectory(instanceId).resolve("backups"); + } + + /// Returns the schematics directory for an instance. + /// + /// @param instanceId the instance id + /// @return the schematics directory below the run directory + default Path getSchematicsDirectory(GameInstanceID instanceId) { + return getRunDirectory(instanceId).resolve("schematics"); + } /// Returns the primary client jar path for a manifest. /// diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java index f36d11ce101..ad279f49745 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryLayout.java @@ -23,37 +23,43 @@ /// Computes repository paths without performing filesystem I/O. /// +/// The methods on this interface describe path concepts that are common across repository +/// layouts used by Minecraft launchers, including the official/vanilla layout and MultiMC-family +/// layouts: a repository base directory, per-instance roots, shared libraries, and shared assets. +/// +/// Layout-specific storage for instance definitions (for example official `versions//.json` +/// files, or MultiMC `mmc-pack.json` / `patches/`) is not part of this interface. +/// /// Implementations must be immutable. Returned paths are derived solely from the layout's base /// directory and the supplied arguments, so callers may safely share a layout between threads. @NotNullByDefault public interface GameRepositoryLayout { - /// Returns the directory containing the files owned by an instance. + /// Returns the repository base directory. /// - /// @param instanceId the instance ID - /// @return the instance root directory - Path getInstanceRoot(GameInstanceID instanceId); - - /// Returns the manifest file for an instance. + /// Shared libraries, assets, and layout-specific instance storage are resolved relative to this + /// directory unless a method documents otherwise. /// - /// @param instanceId the instance ID - /// @return the path `versions//.json` below the base directory - Path getInstanceJson(GameInstanceID instanceId); + /// @return the repository base directory + Path getBaseDirectory(); - /// Returns the conventional client jar file for an instance. + /// Returns the directory containing the files owned by an instance. + /// + /// This is the instance's private storage root (for example official `versions//`, or a + /// MultiMC `instances//` directory). It is not necessarily the launch working directory. /// /// @param instanceId the instance ID - /// @return the path `versions//.jar` below the base directory - Path getInstanceJarFile(GameInstanceID instanceId); + /// @return the instance root directory + Path getInstanceRoot(GameInstanceID instanceId); /// Returns the shared libraries directory. /// - /// @return the path `libraries` below the base directory + /// @return the libraries directory below the base directory Path getLibrariesDirectory(); /// Returns the file used for a library referenced by an instance. /// - /// Libraries with the `local` hint are resolved below the owning instance's `libraries` - /// directory. Other libraries are resolved below the shared libraries directory. + /// Libraries with the `local` hint are resolved below the owning instance's private libraries + /// storage. Other libraries are resolved below the shared libraries directory. /// /// @param owner the ID of the instance that owns the library reference /// @param library the library descriptor @@ -62,7 +68,7 @@ public interface GameRepositoryLayout { /// Returns the shared asset directory. /// - /// @return the path `assets` below the base directory + /// @return the assets directory below the base directory Path getAssetDirectory(); /// Returns the file containing an asset index. From 66196816e38b6944791ee7cd0dcf6013cbe2c2e6 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 01:12:59 +0800 Subject: [PATCH 015/199] Track provisional and modpack install state on HMCLGameInstance via Status Assisted-by: grok-build:grok-4.5 --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 120 +++++++++++++++--- .../hmcl/game/HMCLGameRepository.java | 103 +++++---------- .../hmcl/game/DefaultGameInstance.java | 11 ++ .../hmcl/game/DefaultGameRepository.java | 44 ++++--- 4 files changed, 172 insertions(+), 106 deletions(-) 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 11970d7613d..cd7beb5a16c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -37,14 +37,23 @@ import java.io.IOException; import java.nio.file.Files; +import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.util.Objects; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/// HMCL-specific game instance that owns the lifecycle of instance-local [GameSettings.Instance]. +/// HMCL-specific game instance that owns instance-local settings and run-directory policy. @NotNullByDefault public class HMCLGameInstance extends DefaultGameInstance { + /// Whether this instance is only a provisional placeholder in the current status. + private final boolean provisional; + + /// Whether install-time code currently treats this instance as a modpack for run-directory + /// resolution, before [HMCLGameRepository#isModpack(GameInstanceID)] becomes true. + private boolean treatingAsModpack; + /// Whether the instance-local game settings file has already been inspected. private boolean gameSettingsLoaded; @@ -54,43 +63,65 @@ public class HMCLGameInstance extends DefaultGameInstance { /// Cached instance-local game settings, or `null` when none exist after loading. private GameSettings.@Nullable Instance gameSettings; - /// Creates an instance bound to the given repository status snapshot. + /// Creates a registered instance bound to the given repository status snapshot. /// /// @param status the repository status that owns this instance /// @param id the instance id /// @param manifest the stored instance manifest protected HMCLGameInstance(DefaultGameRepository.Status status, GameInstanceID id, GameInstanceManifest manifest) { - super(status, id, manifest); + this(status, id, manifest, false); } - /// Creates an instance that shares already-loaded game settings with another instance. + /// Creates a provisional instance used before a real manifest is indexed. /// - /// Used when the repository clones a status snapshot or promotes a pending instance so that - /// cached settings remain available on the new wrapper. + /// @param status the repository status that owns this instance + /// @param id the instance id + /// @return a provisional instance with an empty placeholder manifest + static HMCLGameInstance provisional(DefaultGameRepository.Status status, GameInstanceID id) { + return new HMCLGameInstance(status, id, new GameInstanceManifest(id), true); + } + + private HMCLGameInstance( + DefaultGameRepository.Status status, + GameInstanceID id, + GameInstanceManifest manifest, + boolean provisional) { + super(status, id, manifest); + this.provisional = provisional; + } + + /// Creates an instance that shares mutable instance-local state with another instance. /// - /// @param status the repository status that owns this instance - /// @param id the instance id - /// @param manifest the stored instance manifest - /// @param shareGameSettings the instance whose settings fields should be shared + /// Used when the repository clones a status snapshot or promotes a provisional instance so that + /// settings and install-time flags remain available on the new wrapper. private HMCLGameInstance( DefaultGameRepository.Status status, GameInstanceID id, GameInstanceManifest manifest, - HMCLGameInstance shareGameSettings) { + boolean provisional, + HMCLGameInstance shareState) { super(status, id, manifest); - this.gameSettingsLoaded = shareGameSettings.gameSettingsLoaded; - this.gameSettingsReadOnly = shareGameSettings.gameSettingsReadOnly; - this.gameSettings = shareGameSettings.gameSettings; + this.provisional = provisional; + this.treatingAsModpack = shareState.treatingAsModpack; + this.gameSettingsLoaded = shareState.gameSettingsLoaded; + this.gameSettingsReadOnly = shareState.gameSettingsReadOnly; + this.gameSettings = shareState.gameSettings; } @Override protected HMCLGameInstance withNewStatus(DefaultGameRepository.Status newStatus) { - return new HMCLGameInstance(newStatus, id, manifest, this); + return new HMCLGameInstance(newStatus, id, manifest, provisional, this); } @Override protected HMCLGameInstance withManifest(DefaultGameRepository.Status newStatus, GameInstanceManifest manifest) { - return new HMCLGameInstance(newStatus, id, manifest, this); + // A real stored manifest promotes a provisional placeholder to a registered instance. + return new HMCLGameInstance(newStatus, id, manifest, false, this); + } + + @Override + public boolean isProvisional() { + return provisional; } @Override @@ -103,6 +134,63 @@ public HMCLGameRepositoryLayout getLayout() { return (HMCLGameRepositoryLayout) super.getLayout(); } + /// Marks this instance as a modpack for run-directory resolution during installation. + public void markAsModpack() { + treatingAsModpack = true; + } + + /// Clears the install-time modpack mark. + public void unmarkAsModpack() { + treatingAsModpack = false; + } + + /// Returns whether install-time code currently treats this instance as a modpack. + /// + /// @return whether [#markAsModpack()] is in effect + public boolean isTreatingAsModpack() { + return treatingAsModpack; + } + + @Override + public Path getRunDirectory() { + if (treatingAsModpack || getRepository().isModpack(id)) { + return getInstanceRoot(); + } + + GameSettings.Instance localSetting = getSettings(); + boolean useInstanceRunningDirectory = + localSetting != null + && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY); + + String runningDirectory = selectedRunningDirectory(localSetting, useInstanceRunningDirectory); + if (StringUtils.isBlank(runningDirectory)) { + return useInstanceRunningDirectory ? getInstanceRoot() : getLayout().getBaseDirectory(); + } + + try { + return Path.of(runningDirectory); + } catch (InvalidPathException ignored) { + return getInstanceRoot(); + } + } + + private String selectedRunningDirectory( + @Nullable GameSettings.Instance localSetting, + boolean useInstanceRunningDirectory) { + if (useInstanceRunningDirectory) { + if (localSetting == null) { + return ""; + } + + //noinspection DataFlowIssue + return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), ""); + } + + GameSettings.Preset parent = getRepository().getParentGameSettings(localSetting); + //noinspection DataFlowIssue + return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), ""); + } + /// Returns the loaded instance-local game settings, loading them on first access. /// /// @return the settings, or `null` when no local settings exist after loading 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 862811faa30..f4eadc72689 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -56,7 +56,6 @@ import java.io.IOException; import java.nio.file.Files; -import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.time.Instant; import java.util.*; @@ -84,14 +83,6 @@ public record InstanceReference(HMCLGameRepository repository, @Nullable GameIns /// The selected instance ID persisted for this repository's game directory. private final ObjectBinding<@Nullable GameInstanceID> selectedInstance; - /// Instances that are not yet present in the repository index. - /// - /// Used during new-instance installation and similar flows that need isolation settings before - /// the instance manifest has been saved and indexed. - private final Map pendingInstances = new HashMap<>(); - - private final Set beingModpackInstances = new HashSet<>(); - public final EventManager onInstanceIconChanged = new EventManager<>(); /// Creates a repository backed by the given game directory. @@ -109,9 +100,9 @@ protected HMCLGameRepositoryLayout createLayout(Path baseDirectory) { @Override protected HMCLGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { - HMCLGameInstance pending = pendingInstances.remove(id); - if (pending != null) { - return pending.withManifest(status, manifest); + DefaultGameInstance existing = status.instances.get(id); + if (existing instanceof HMCLGameInstance hmcl) { + return hmcl.withManifest(status, manifest); } return new HMCLGameInstance(status, id, manifest); } @@ -128,6 +119,8 @@ public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstance /// Returns the indexed instance for the given id, or `null` when it is not loaded. /// + /// Provisional placeholders are excluded. + /// /// @param id the instance id /// @return the instance, or `null` when absent public @Nullable HMCLGameInstance findInstance(GameInstanceID id) { @@ -138,21 +131,25 @@ public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstance } } - /// Returns the instance that owns local game settings for the given id. + /// Returns the instance that owns local state for the given id. /// - /// When the instance is already indexed, that instance is returned. Otherwise a pending - /// [HMCLGameInstance] is created and retained until the instance is registered or the - /// repository is refreshed. + /// When the id is already present in the current [Status] (including provisional + /// placeholders), that instance is returned. Otherwise a provisional [HMCLGameInstance] is + /// created and recorded in the current status until it is promoted by a real manifest or the + /// status is replaced by refresh. /// /// @param instanceId the instance id - /// @return the instance used to manage settings for the id + /// @return the instance used to manage settings and install-time state for the id private HMCLGameInstance resolveInstance(GameInstanceID instanceId) { - HMCLGameInstance instance = findInstance(instanceId); - if (instance != null) { - return instance; + DefaultGameInstance existing = findStatusInstance(instanceId); + if (existing instanceof HMCLGameInstance hmcl) { + return hmcl; } - return pendingInstances.computeIfAbsent( - instanceId, id -> new HMCLGameInstance(currentStatus(), id, new GameInstanceManifest(id))); + + Status current = currentStatus(); + HMCLGameInstance provisional = HMCLGameInstance.provisional(current, instanceId); + current.instances.put(instanceId, provisional); + return provisional; } /// Returns the persistent game directory for this repository. @@ -199,42 +196,7 @@ public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) @Override public Path getRunDirectory(GameInstanceID instanceId) { - if (beingModpackInstances.contains(instanceId) || isModpack(instanceId)) { - return getLayout().getInstanceRoot(instanceId); - } - - GameSettings.Instance localSetting = getInstanceGameSettings(instanceId); - boolean useInstanceRunningDirectory = - localSetting != null && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY); - - String runningDirectory = getSelectedRunningDirectory(localSetting, useInstanceRunningDirectory); - if (StringUtils.isBlank(runningDirectory)) { - return useInstanceRunningDirectory ? getLayout().getInstanceRoot(instanceId) : super.getRunDirectory(instanceId); - } - - try { - return Path.of(runningDirectory); - } catch (InvalidPathException ignored) { - return getLayout().getInstanceRoot(instanceId); - } - } - - /// Returns the running directory string selected by the current source. - private String getSelectedRunningDirectory( - @Nullable GameSettings.Instance localSetting, - boolean useInstanceRunningDirectory) { - if (useInstanceRunningDirectory) { - if (localSetting == null) { - return ""; - } - - //noinspection DataFlowIssue - return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), ""); - } - - GameSettings.Preset parent = getParentGameSettings(localSetting); - //noinspection DataFlowIssue - return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), ""); + return resolveInstance(instanceId).getRunDirectory(); } public Stream getDisplayInstanceManifests() { @@ -246,7 +208,6 @@ public Stream getDisplayInstanceManifests() { @Override protected void refreshImpl() { - pendingInstances.clear(); super.refreshImpl(); try { @@ -275,17 +236,6 @@ public void clean(GameInstanceID instanceId) throws IOException { clean(getRunDirectory(instanceId)); } - /// Removes an instance from disk and clears its cached HMCL settings state. - @Override - public boolean removeInstanceFromDisk(GameInstanceID instanceId) { - boolean removed = super.removeInstanceFromDisk(instanceId); - if (removed) { - pendingInstances.remove(instanceId); - beingModpackInstances.remove(instanceId); - } - return removed; - } - public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException { Path srcDir = getLayout().getInstanceRoot(srcId); Path dstDir = getLayout().getInstanceRoot(dstId); @@ -632,12 +582,21 @@ public Path getModpackConfiguration(GameInstanceID instanceId) { return getLayout().getInstanceRoot(instanceId).resolve("modpack.cfg"); } + /// Marks the instance as a modpack for run-directory resolution during installation. + /// + /// @param instanceId the instance id public void markInstanceAsModpack(GameInstanceID instanceId) { - beingModpackInstances.add(instanceId); + resolveInstance(instanceId).markAsModpack(); } + /// Clears the install-time modpack mark for the instance. + /// + /// @param instanceId the instance id public void undoMark(GameInstanceID instanceId) { - beingModpackInstances.remove(instanceId); + DefaultGameInstance existing = findStatusInstance(instanceId); + if (existing instanceof HMCLGameInstance hmcl) { + hmcl.unmarkAsModpack(); + } } public void markInstanceLaunchedAbnormally(GameInstanceID instanceId) { 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 97c1e32af5a..c8018ed6f16 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -70,6 +70,17 @@ public GameInstanceID getId() { return id; } + /// Returns whether this instance is only a provisional placeholder. + /// + /// Provisional instances may appear in the current [DefaultGameRepository.Status] so that + /// instance-local state (for example install-time settings) can be tracked before a real + /// manifest is saved. They must not be treated as indexed repository members. + /// + /// @return `false` by default + public boolean isProvisional() { + return false; + } + @Override public GameInstanceManifest getManifest() { return manifest; 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 62b227ba4c0..8274ed8027c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -275,50 +275,58 @@ private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, G @Override public boolean hasInstance(GameInstanceID instanceId) { - return status.instances.containsKey(instanceId); + DefaultGameInstance instance = status.instances.get(instanceId); + return instance != null && !instance.isProvisional(); } @Override public GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { - DefaultGameInstance instance = status.instances.get(instanceId); - if (instance == null) { - throw new NoSuchGameInstanceException(instanceId); - } - return instance.getManifest(); + return getInstance(instanceId).getManifest(); } @Override public GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { - Status currentStatus = status; - - DefaultGameInstance instance = currentStatus.instances.get(instanceId); - if (instance == null) { - throw new NoSuchGameInstanceException(instanceId); - } - - return instance.getResolvedManifest(); + return getInstance(instanceId).getResolvedManifest(); } @Override public int getInstanceCount() { - return status.instances.size(); + int count = 0; + for (DefaultGameInstance instance : status.instances.values()) { + if (!instance.isProvisional()) { + count++; + } + } + return count; } @Override public Collection getInstanceManifests() { - return status.instances.values().stream().map(i -> i.manifest).toList(); + return status.instances.values().stream() + .filter(instance -> !instance.isProvisional()) + .map(instance -> instance.manifest) + .toList(); } @Override - public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException{ + public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { @Nullable DefaultGameInstance instance = status.instances.get(id); - if (instance != null) { + if (instance != null && !instance.isProvisional()) { return instance; } else { throw new NoSuchGameInstanceException(id); } } + /// Returns the instance recorded in the current status for the given id, including provisional + /// placeholders. + /// + /// @param id the instance id + /// @return the instance, or `null` when absent from the current status + protected @Nullable DefaultGameInstance findStatusInstance(GameInstanceID id) { + return status.instances.get(id); + } + public Path getArtifactFile(GameInstanceManifest manifest, Artifact artifact) { return artifact.getPath(getLayout().getLibrariesDirectory()); } From 52f37da8009683b0b727a5647036981cc29d784d Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 01:20:47 +0800 Subject: [PATCH 016/199] Cache game version on DefaultGameInstance instead of repository map Assisted-by: grok-build:grok-4.5 --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 1 + .../hmcl/game/DefaultGameInstance.java | 37 +++++++++++++++- .../hmcl/game/DefaultGameRepository.java | 42 ++++++++++++------- 3 files changed, 63 insertions(+), 17 deletions(-) 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 cd7beb5a16c..291d6b6cae8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -106,6 +106,7 @@ private HMCLGameInstance( this.gameSettingsLoaded = shareState.gameSettingsLoaded; this.gameSettingsReadOnly = shareState.gameSettingsReadOnly; this.gameSettings = shareState.gameSettings; + this.version = shareState.version; } @Override 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 c8018ed6f16..5f55471b2cf 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -21,8 +21,12 @@ import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import java.nio.file.Files; import java.nio.file.Path; import java.util.HashSet; +import java.util.Optional; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; @NotNullByDefault public abstract class DefaultGameInstance implements GameInstance { @@ -33,6 +37,11 @@ public abstract class DefaultGameInstance implements GameInstance { protected final GameInstanceID id; protected final GameInstanceManifest manifest; protected GameInstanceManifest.@Nullable Resolved resolvedManifest; + + /// Cached Minecraft game version detected from this instance's primary jar. + /// + /// `null` means detection has not been attempted yet. After detection, unknown results are + /// stored as [GameVersionNumber#unknown()] rather than left null. protected @Nullable GameVersionNumber version; protected DefaultGameInstance( @@ -94,14 +103,40 @@ public GameInstanceManifest.Resolved getResolvedManifest() { return resolvedManifest; } + /// {@inheritDoc} + /// + /// The detected version is cached on this instance. When the primary jar cannot be resolved or + /// its Minecraft version cannot be recognized, [GameVersionNumber#unknown()] is cached and + /// returned. @Override public GameVersionNumber getVersion() { if (version == null) { - version = GameVersionNumber.asGameVersion(repository.getGameVersion(getId())); // TODO + version = detectVersion(); } return version; } + /// Detects the Minecraft game version from this instance's primary client jar. + /// + /// @return the detected version, or [GameVersionNumber#unknown()] when detection fails + private GameVersionNumber detectVersion() { + try { + GameInstanceManifest launchManifest = getResolvedManifest().launchManifest(); + Path jar = repository.getInstanceJar(launchManifest); + Optional detected = GameVersion.minecraftVersion(jar); + if (detected.isEmpty()) { + LOG.warning("Cannot find out game version of " + id + + ", primary jar: " + jar + + ", jar exists: " + Files.exists(jar)); + return GameVersionNumber.unknown(); + } + return GameVersionNumber.asGameVersion(detected.get()); + } catch (NoSuchGameInstanceException e) { + LOG.warning("Cannot resolve game version of " + id, e); + return GameVersionNumber.unknown(); + } + } + @Override public Path getInstanceRoot() { return layout.getInstanceRoot(id); 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 8274ed8027c..0a621d008ce 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -27,6 +27,7 @@ import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -36,7 +37,6 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.*; -import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -88,7 +88,6 @@ private static boolean hasClassicVersion(Path baseDirectory) { private volatile Status status; private volatile boolean loaded; - private final ConcurrentHashMap> gameVersions = new ConcurrentHashMap<>(); public DefaultGameRepository(Path baseDirectory) { this.status = new Status(this, createLayout(baseDirectory)); @@ -103,7 +102,6 @@ public DefaultGameRepository(Path baseDirectory) { public void setBaseDirectory(Path baseDirectory) { this.status = new Status(this, createLayout(baseDirectory)); this.loaded = false; - this.gameVersions.clear(); } /// Returns the current repository status snapshot. @@ -233,7 +231,6 @@ protected void refreshImpl() { newStatus.instances.clear(); newStatus.instances.putAll(loadedInstances); - gameVersions.clear(); this.status = newStatus; } @@ -382,7 +379,6 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { currentStatus.instances.clear(); currentStatus.instances.putAll(updatedInstances); - gameVersions.clear(); return true; } catch (IOException | JsonParseException | NoSuchGameInstanceException | InvalidPathException e) { LOG.warning("Unable to rename version " + from + " to " + to, e); @@ -435,20 +431,36 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { } } + @Override + public Optional getGameVersion(GameInstanceID instanceId) throws NoSuchGameInstanceException { + GameVersionNumber version = getInstance(instanceId).getVersion(); + if (version == GameVersionNumber.unknown()) { + return Optional.empty(); + } + return Optional.of(version.toString()); + } + @Override public Optional getGameVersion(GameInstanceManifest manifest) { + DefaultGameInstance instance = findStatusInstance(manifest.id()); + if (instance != null && !instance.isProvisional()) { + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + return Optional.empty(); + } + return Optional.of(version.toString()); + } + try { GameInstanceManifest resolved = resolve(manifest).launchManifest(); Path instanceJar = getInstanceJar(resolved); - return gameVersions.computeIfAbsent(instanceJar, jar -> { - Optional gameVersion = GameVersion.minecraftVersion(jar); - if (gameVersion.isEmpty()) { - LOG.warning("Cannot find out game version of " + manifest.id() - + ", primary jar: " + jar - + ", jar exists: " + Files.exists(jar)); - } - return gameVersion; - }); + Optional gameVersion = GameVersion.minecraftVersion(instanceJar); + if (gameVersion.isEmpty()) { + LOG.warning("Cannot find out game version of " + manifest.id() + + ", primary jar: " + instanceJar + + ", jar exists: " + Files.exists(instanceJar)); + } + return gameVersion; } catch (NoSuchGameInstanceException e) { return Optional.empty(); } @@ -560,8 +572,6 @@ public Task saveAsync(GameInstanceManifest instanceManifes newStatus.instances.put(savedManifest.id(), createInstance(newStatus, savedManifest.id(), savedManifest)); } status = newStatus; - - gameVersions.clear(); return savedManifest; }); } From 766a696a3c055fd63ba95179d5dca8c48269fe85 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 01:39:43 +0800 Subject: [PATCH 017/199] Seal published Status snapshots and update repository via copy-on-write Assisted-by: grok-build:grok-4.5 --- .../hmcl/game/HMCLGameRepository.java | 11 +- .../hmcl/game/DefaultGameRepository.java | 171 ++++++++++++++---- 2 files changed, 143 insertions(+), 39 deletions(-) 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 f4eadc72689..a955f7acd4e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -100,7 +100,7 @@ protected HMCLGameRepositoryLayout createLayout(Path baseDirectory) { @Override protected HMCLGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { - DefaultGameInstance existing = status.instances.get(id); + DefaultGameInstance existing = status.get(id); if (existing instanceof HMCLGameInstance hmcl) { return hmcl.withManifest(status, manifest); } @@ -135,7 +135,7 @@ public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstance /// /// When the id is already present in the current [Status] (including provisional /// placeholders), that instance is returned. Otherwise a provisional [HMCLGameInstance] is - /// created and recorded in the current status until it is promoted by a real manifest or the + /// created and published in a new status until it is promoted by a real manifest or the /// status is replaced by refresh. /// /// @param instanceId the instance id @@ -146,9 +146,10 @@ private HMCLGameInstance resolveInstance(GameInstanceID instanceId) { return hmcl; } - Status current = currentStatus(); - HMCLGameInstance provisional = HMCLGameInstance.provisional(current, instanceId); - current.instances.put(instanceId, provisional); + Status newStatus = currentStatus().clone(); + HMCLGameInstance provisional = HMCLGameInstance.provisional(newStatus, instanceId); + newStatus.put(provisional); + publishStatus(newStatus); return provisional; } 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 0a621d008ce..a126bff94fc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -90,7 +90,9 @@ private static boolean hasClassicVersion(Path baseDirectory) { private volatile boolean loaded; public DefaultGameRepository(Path baseDirectory) { - this.status = new Status(this, createLayout(baseDirectory)); + Status initial = new Status(this, createLayout(baseDirectory)); + initial.seal(); + this.status = initial; } /// Creates the repository layout rooted at the given directory. @@ -100,17 +102,30 @@ public DefaultGameRepository(Path baseDirectory) { protected abstract DefaultGameRepositoryLayout createLayout(Path baseDirectory); public void setBaseDirectory(Path baseDirectory) { - this.status = new Status(this, createLayout(baseDirectory)); + Status initial = new Status(this, createLayout(baseDirectory)); + publishStatus(initial); this.loaded = false; } - /// Returns the current repository status snapshot. + /// Returns the current published repository status snapshot. + /// + /// The returned status is sealed and must not be modified. Writers must [#clone()] it, edit the + /// copy, and publish the result with [#publishStatus(Status)]. /// /// @return the current status protected Status currentStatus() { return status; } + /// Seals `newStatus` if needed and publishes it as the current repository snapshot. + /// + /// @param newStatus the status to publish; must not already be visible as [#currentStatus()] + /// unless it is a freshly built replacement + protected void publishStatus(Status newStatus) { + newStatus.seal(); + this.status = newStatus; + } + @Override public DefaultGameRepositoryLayout getLayout() { return status.layout; @@ -136,7 +151,7 @@ protected void refreshImpl() { if (hasClassicVersion(newStatus.layout.getBaseDirectory())) { GameInstanceID id = CLASSIC_MANIFEST.id(); - newStatus.instances.put(id, createInstance(newStatus, id, CLASSIC_MANIFEST)); + newStatus.put(createInstance(newStatus, id, CLASSIC_MANIFEST)); } Path versionsDir = newStatus.layout.getBaseDirectory().resolve("versions"); @@ -209,16 +224,14 @@ protected void refreshImpl() { } return Stream.of(manifest); - }).forEachOrdered(it -> newStatus.instances.put( - it.id(), - createInstance(newStatus, it.id(), it))); + }).forEachOrdered(it -> newStatus.put(createInstance(newStatus, it.id(), it))); } catch (IOException e) { LOG.warning("Failed to load versions from " + versionsDir, e); } } Map loadedInstances = new TreeMap<>(); - for (DefaultGameInstance instance : newStatus.instances.values()) { + for (DefaultGameInstance instance : newStatus.values()) { try { GameInstanceManifest resolved = newStatus.resolve(instance.getManifest(), new HashSet<>()).launchManifest(); if (CompatibilityRule.appliesToCurrentEnvironment(resolved.compatibilityRules())) { @@ -229,9 +242,9 @@ protected void refreshImpl() { } } - newStatus.instances.clear(); - newStatus.instances.putAll(loadedInstances); - this.status = newStatus; + newStatus.clear(); + newStatus.putAll(loadedInstances); + publishStatus(newStatus); } private static GameInstanceManifest readInstanceManifest(Path json) throws IOException, JsonParseException { @@ -272,7 +285,7 @@ private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, G @Override public boolean hasInstance(GameInstanceID instanceId) { - DefaultGameInstance instance = status.instances.get(instanceId); + DefaultGameInstance instance = status.get(instanceId); return instance != null && !instance.isProvisional(); } @@ -289,7 +302,7 @@ public GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID @Override public int getInstanceCount() { int count = 0; - for (DefaultGameInstance instance : status.instances.values()) { + for (DefaultGameInstance instance : status.values()) { if (!instance.isProvisional()) { count++; } @@ -299,7 +312,7 @@ public int getInstanceCount() { @Override public Collection getInstanceManifests() { - return status.instances.values().stream() + return status.values().stream() .filter(instance -> !instance.isProvisional()) .map(instance -> instance.manifest) .toList(); @@ -307,7 +320,7 @@ public Collection getInstanceManifests() { @Override public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { - @Nullable DefaultGameInstance instance = status.instances.get(id); + @Nullable DefaultGameInstance instance = status.get(id); if (instance != null && !instance.isProvisional()) { return instance; } else { @@ -321,7 +334,7 @@ public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInsta /// @param id the instance id /// @return the instance, or `null` when absent from the current status protected @Nullable DefaultGameInstance findStatusInstance(GameInstanceID id) { - return status.instances.get(id); + return status.get(id); } public Path getArtifactFile(GameInstanceManifest manifest, Artifact artifact) { @@ -347,13 +360,13 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { } try { - Status currentStatus = status; - DefaultGameInstance fromHolder = currentStatus.instances.get(from); - if (fromHolder == null) { + Status newStatus = status.clone(); + DefaultGameInstance fromHolder = newStatus.get(from); + if (fromHolder == null || fromHolder.isProvisional()) { throw new NoSuchGameInstanceException(from); } - moveInstanceFiles(currentStatus.layout.getBaseDirectory(), from, to); + moveInstanceFiles(newStatus.layout.getBaseDirectory(), from, to); GameInstanceManifest renamedManifest = fromHolder.manifest; if (from.equals(renamedManifest.jar())) { @@ -362,23 +375,21 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { renamedManifest = renamedManifest.withId(to); JsonUtils.writeToJsonFile(getInstanceJson(to), renamedManifest); - Map updatedInstances = new TreeMap<>(currentStatus.instances); - updatedInstances.remove(from); - updatedInstances.put(to, createInstance(currentStatus, to, renamedManifest)); + newStatus.remove(from); + newStatus.put(fromHolder.withManifest(newStatus, renamedManifest)); - for (DefaultGameInstance instance : currentStatus.instances.values()) { + for (DefaultGameInstance instance : List.copyOf(newStatus.values())) { GameInstanceManifest manifest = instance.manifest; if (from.equals(manifest.inheritsFrom())) { GameInstanceManifest updatedManifest = manifest.withInheritsFrom(to); Path targetPath = getInstanceJson(updatedManifest.id()); Files.createDirectories(targetPath.getParent()); JsonUtils.writeToJsonFile(targetPath, updatedManifest); - updatedInstances.put(updatedManifest.id(), createInstance(currentStatus, updatedManifest.id(), updatedManifest)); + newStatus.put(instance.withManifest(newStatus, updatedManifest)); } } - currentStatus.instances.clear(); - currentStatus.instances.putAll(updatedInstances); + publishStatus(newStatus); return true; } catch (IOException | JsonParseException | NoSuchGameInstanceException | InvalidPathException e) { LOG.warning("Unable to rename version " + from + " to " + to, e); @@ -391,8 +402,11 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { return false; } - Status currentStatus = status; - currentStatus.instances.remove(id); + if (status.get(id) != null) { + Status newStatus = status.clone(); + newStatus.remove(id); + publishStatus(newStatus); + } Path file = getLayout().getInstanceRoot(id); if (Files.notExists(file)) { @@ -565,13 +579,13 @@ public Task saveAsync(GameInstanceManifest instanceManifes JsonUtils.writeToJsonFile(json, savedManifest); Status newStatus = status.clone(); - DefaultGameInstance existing = newStatus.instances.get(savedManifest.id()); + DefaultGameInstance existing = newStatus.get(savedManifest.id()); if (existing != null) { - newStatus.instances.put(savedManifest.id(), existing.withManifest(newStatus, savedManifest)); + newStatus.put(existing.withManifest(newStatus, savedManifest)); } else { - newStatus.instances.put(savedManifest.id(), createInstance(newStatus, savedManifest.id(), savedManifest)); + newStatus.put(createInstance(newStatus, savedManifest.id(), savedManifest)); } - status = newStatus; + publishStatus(newStatus); return savedManifest; }); } @@ -607,16 +621,105 @@ public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) thro protected abstract DefaultGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest); + /// Immutable snapshot of the repository index once published. + /// + /// A status begins unsealed so that writers can populate it. [#seal()] freezes the instance map; + /// afterwards any mutating method throws. Callers must [#clone()] a published status, edit the + /// copy, and publish it with [DefaultGameRepository#publishStatus(Status)]. protected static class Status { public final DefaultGameRepository repository; public final DefaultGameRepositoryLayout layout; - public final Map instances = new TreeMap<>(); + private Map instances; + private boolean sealed; + /// Creates an empty unsealed status for building a new snapshot. + /// + /// @param repository the owning repository + /// @param layout the layout for this snapshot protected Status(DefaultGameRepository repository, DefaultGameRepositoryLayout layout) { this.repository = repository; this.layout = layout; + this.instances = new TreeMap<>(); + this.sealed = false; + } + + /// Freezes this status so its instance map can no longer be modified. + void seal() { + if (!sealed) { + instances = Collections.unmodifiableMap(new TreeMap<>(instances)); + sealed = true; + } + } + + /// Returns whether this status has been sealed. + /// + /// @return whether mutation is forbidden + public boolean isSealed() { + return sealed; + } + + private void checkMutable() { + if (sealed) { + throw new IllegalStateException("Status has been published and cannot be modified"); + } + } + + /// Returns the instance with the given id, including provisional placeholders. + /// + /// @param id the instance id + /// @return the instance, or `null` when absent + public @Nullable DefaultGameInstance get(GameInstanceID id) { + return instances.get(id); + } + + /// Returns a view of all instances in this status, including provisional placeholders. + /// + /// @return the instances; unmodifiable after [#seal()] + public Collection values() { + return instances.values(); + } + + /// Returns an unmodifiable map view after seal, or the live map while building. + /// + /// @return the instance map + public Map asMap() { + return instances; + } + + /// Adds or replaces an instance in this unsealed status. + /// + /// @param instance the instance bound to this status + void put(DefaultGameInstance instance) { + checkMutable(); + instances.put(instance.getId(), instance); + } + + /// Adds or replaces all instances from the given map. + /// + /// @param map instances keyed by id + void putAll(Map map) { + checkMutable(); + instances.putAll(map); + } + + /// Removes the instance with the given id. + /// + /// @param id the instance id + void remove(GameInstanceID id) { + checkMutable(); + instances.remove(id); + } + + /// Removes all instances from this unsealed status. + void clear() { + checkMutable(); + instances.clear(); } + /// Creates an unsealed copy of this status with instances rebound to the copy. + /// + /// @return a mutable status ready for further edits before publish + @Override public Status clone() { Status newStatus = new Status(repository, layout); for (DefaultGameInstance instance : instances.values()) { From d8592e7c8c3b58f88261c77d3f04d0bba67ab18a Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 01:57:05 +0800 Subject: [PATCH 018/199] Expose sealed repository index as public GameRepositorySnapshot Assisted-by: grok-build:grok-4.5 --- .../hmcl/game/HMCLGameRepository.java | 7 +- .../hmcl/game/DefaultGameRepository.java | 133 ++++++++++++------ .../org/jackhuang/hmcl/game/GameInstance.java | 10 +- .../jackhuang/hmcl/game/GameRepository.java | 37 ++++- .../hmcl/game/GameRepositorySnapshot.java | 85 +++++++++++ 5 files changed, 214 insertions(+), 58 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java 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 a955f7acd4e..358273bee5e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -124,11 +124,8 @@ public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstance /// @param id the instance id /// @return the instance, or `null` when absent public @Nullable HMCLGameInstance findInstance(GameInstanceID id) { - try { - return getInstance(id); - } catch (NoSuchGameInstanceException e) { - return null; - } + GameInstance instance = getSnapshot().findInstance(id); + return instance instanceof HMCLGameInstance hmcl ? hmcl : null; } /// Returns the instance that owns local state for the given id. 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 a126bff94fc..6cf734d94f8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -117,6 +117,12 @@ protected Status currentStatus() { return status; } + /// {@inheritDoc} + @Override + public GameRepositorySnapshot getSnapshot() { + return status; + } + /// Seals `newStatus` if needed and publishes it as the current repository snapshot. /// /// @param newStatus the status to publish; must not already be visible as [#currentStatus()] @@ -283,49 +289,9 @@ private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, G } } - @Override - public boolean hasInstance(GameInstanceID instanceId) { - DefaultGameInstance instance = status.get(instanceId); - return instance != null && !instance.isProvisional(); - } - - @Override - public GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getInstance(instanceId).getManifest(); - } - - @Override - public GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getInstance(instanceId).getResolvedManifest(); - } - - @Override - public int getInstanceCount() { - int count = 0; - for (DefaultGameInstance instance : status.values()) { - if (!instance.isProvisional()) { - count++; - } - } - return count; - } - - @Override - public Collection getInstanceManifests() { - return status.values().stream() - .filter(instance -> !instance.isProvisional()) - .map(instance -> instance.manifest) - .toList(); - } - @Override public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { - @Nullable DefaultGameInstance instance = status.get(id); - if (instance != null && !instance.isProvisional()) { - return instance; - } else { - throw new NoSuchGameInstanceException(id); - } + return status.getRegistered(id); } /// Returns the instance recorded in the current status for the given id, including provisional @@ -621,12 +587,16 @@ public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) thro protected abstract DefaultGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest); - /// Immutable snapshot of the repository index once published. + /// Mutable builder and sealed published snapshot of the repository index. /// /// A status begins unsealed so that writers can populate it. [#seal()] freezes the instance map; /// afterwards any mutating method throws. Callers must [#clone()] a published status, edit the /// copy, and publish it with [DefaultGameRepository#publishStatus(Status)]. - protected static class Status { + /// + /// Once sealed, this object is exposed as a [GameRepositorySnapshot]. Provisional placeholders + /// remain reachable through package/internal accessors such as [#get(GameInstanceID)] but are + /// excluded from the public snapshot view. + protected static class Status implements GameRepositorySnapshot { public final DefaultGameRepository repository; public final DefaultGameRepositoryLayout layout; private Map instances; @@ -664,6 +634,18 @@ private void checkMutable() { } } + /// {@inheritDoc} + @Override + public DefaultGameRepository getRepository() { + return repository; + } + + /// {@inheritDoc} + @Override + public DefaultGameRepositoryLayout getLayout() { + return layout; + } + /// Returns the instance with the given id, including provisional placeholders. /// /// @param id the instance id @@ -672,6 +654,71 @@ private void checkMutable() { return instances.get(id); } + /// Returns the registered instance with the given id. + /// + /// @param id the instance id + /// @return the registered instance + /// @throws NoSuchGameInstanceException if the instance is absent or provisional + public DefaultGameInstance getRegistered(GameInstanceID id) throws NoSuchGameInstanceException { + DefaultGameInstance instance = instances.get(id); + if (instance != null && !instance.isProvisional()) { + return instance; + } + throw new NoSuchGameInstanceException(id); + } + + /// {@inheritDoc} + @Override + public boolean hasInstance(GameInstanceID instanceId) { + DefaultGameInstance instance = instances.get(instanceId); + return instance != null && !instance.isProvisional(); + } + + /// {@inheritDoc} + @Override + public DefaultGameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException { + return getRegistered(instanceId); + } + + /// {@inheritDoc} + @Override + public @Nullable DefaultGameInstance findInstance(GameInstanceID instanceId) { + DefaultGameInstance instance = instances.get(instanceId); + if (instance != null && !instance.isProvisional()) { + return instance; + } + return null; + } + + /// {@inheritDoc} + @Override + public int getInstanceCount() { + int count = 0; + for (DefaultGameInstance instance : instances.values()) { + if (!instance.isProvisional()) { + count++; + } + } + return count; + } + + /// {@inheritDoc} + @Override + public Collection getInstances() { + return instances.values().stream() + .filter(instance -> !instance.isProvisional()) + .toList(); + } + + /// {@inheritDoc} + @Override + public Collection getInstanceManifests() { + return instances.values().stream() + .filter(instance -> !instance.isProvisional()) + .map(instance -> instance.manifest) + .toList(); + } + /// Returns a view of all instances in this status, including provisional placeholders. /// /// @return the instances; unmodifiable after [#seal()] 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 361e2f16612..2a15a3b37f8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -23,11 +23,13 @@ import java.nio.file.Path; -/// Provides an immutable view of a game instance and its instance-specific paths. +/// Provides a view of a game instance and its instance-specific paths within a +/// [GameRepositorySnapshot]. /// -/// Core repository implementations replace instances as complete values when repository state -/// changes. Callers that need a long-lived identity must use a higher-level implementation that -/// explicitly provides that guarantee. +/// Core repository implementations publish instances as values belonging to a sealed snapshot. +/// When the repository publishes a newer snapshot, previously obtained instances may be stale. +/// Callers that need a long-lived identity should retain a [GameInstanceID] (or a higher-level +/// handle) and resolve it again from [GameRepository#getSnapshot()]. @NotNullByDefault public interface GameInstance { 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 dde21aaf6e3..08d85d08c61 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -32,6 +32,10 @@ /// Provides indexed access to local game instances and the filesystem layout used by those instances. /// +/// The registered instance index is published as immutable [GameRepositorySnapshot] values. Readers +/// that need a consistent view across multiple lookups should retain [#getSnapshot()] rather than +/// interleaving queries with repository writes such as [#refresh()]. +/// /// Implementations are responsible for loading instance manifests, resolving inheritance and patches, /// locating instance-owned files, and exposing helper paths used by launch, download, and maintenance code. /// @@ -52,6 +56,14 @@ default Path getBaseDirectory() { return getLayout().getBaseDirectory(); } + /// Returns the current published snapshot of the registered instance index. + /// + /// The snapshot is immutable. Subsequent repository writes publish a replacement snapshot and + /// do not mutate the returned object. + /// + /// @return the current repository snapshot + GameRepositorySnapshot getSnapshot(); + /// Resolves inheritance into launch and standalone manifest views. /// /// @param manifest the manifest to resolve @@ -62,37 +74,50 @@ default Path getBaseDirectory() { /// /// @param instanceId the instance id /// @return whether the instance exists - boolean hasInstance(GameInstanceID instanceId); + default boolean hasInstance(GameInstanceID instanceId) { + return getSnapshot().hasInstance(instanceId); + } /// Returns the stored manifest for an instance without resolving inheritance or patches. /// /// @param instanceId the instance id /// @return the stored instance manifest /// @throws NoSuchGameInstanceException if the instance is not loaded in this repository - GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException; + default GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException { + return getSnapshot().getInstance(instanceId).getManifest(); + } /// Returns a cached launch-ready manifest view for the instance. /// /// @param instanceId the instance id /// @return the resolved manifest view - GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) throws NoSuchGameInstanceException; + default GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) + throws NoSuchGameInstanceException { + return getSnapshot().getInstance(instanceId).getResolvedManifest(); + } /// Returns the number of loaded instances. /// /// @return the loaded instance count - int getInstanceCount(); + default int getInstanceCount() { + return getSnapshot().getInstanceCount(); + } /// Returns the stored manifests for all loaded instances. /// /// @return the loaded instance manifests - Collection getInstanceManifests(); + default Collection getInstanceManifests() { + return getSnapshot().getInstanceManifests(); + } /// Returns the indexed game instance for the given id. /// /// @param id the instance id /// @return the game instance /// @throws NoSuchGameInstanceException if the instance is not loaded in this repository - GameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException; + default GameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { + return getSnapshot().getInstance(id); + } /// Reloads repository state from the backing storage. void refresh(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java new file mode 100644 index 00000000000..f285a228fc9 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java @@ -0,0 +1,85 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.Collection; + +/// An immutable snapshot of a [GameRepository] instance index. +/// +/// A snapshot is published as a complete value. After publication it is never mutated: repository +/// writers replace the current snapshot rather than editing a live map. Callers that need a stable +/// view across multiple lookups should retain the snapshot returned by +/// [GameRepository#getSnapshot()] instead of repeatedly querying the repository. +/// +/// [GameInstance] values obtained from a snapshot belong to that snapshot. After the repository +/// publishes a newer snapshot, previously obtained instances may be stale; request them again from +/// the current snapshot or repository when up-to-date state is required. +/// +/// Snapshot queries describe **registered** instances only. Implementation-specific provisional +/// placeholders used during installation are not part of this view. +@NotNullByDefault +public interface GameRepositorySnapshot { + /// Returns the repository that published this snapshot. + /// + /// @return the owning repository + GameRepository getRepository(); + + /// Returns the filesystem layout associated with this snapshot. + /// + /// @return the repository layout + GameRepositoryLayout getLayout(); + + /// Returns whether a registered instance with the given id exists in this snapshot. + /// + /// @param instanceId the instance id + /// @return whether the instance is registered + boolean hasInstance(GameInstanceID instanceId); + + /// Returns the registered instance with the given id. + /// + /// @param instanceId the instance id + /// @return the instance + /// @throws NoSuchGameInstanceException if the instance is not registered in this snapshot + GameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException; + + /// Returns the registered instance with the given id, or `null` when absent. + /// + /// @param instanceId the instance id + /// @return the instance, or `null` when not registered + @Nullable GameInstance findInstance(GameInstanceID instanceId); + + /// Returns the number of registered instances in this snapshot. + /// + /// @return the registered instance count + int getInstanceCount(); + + /// Returns the registered instances in this snapshot. + /// + /// The returned collection is unmodifiable and reflects only this snapshot. + /// + /// @return the registered instances + Collection getInstances(); + + /// Returns the stored manifests of all registered instances in this snapshot. + /// + /// @return the registered instance manifests + Collection getInstanceManifests(); +} From 767adeaa99e5f609c77c02b48d14b58923da8751 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 02:01:06 +0800 Subject: [PATCH 019/199] Refactor DefaultGameRepository.Status to DefaultGameRepositoryStatus for consistency --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 12 +- .../hmcl/game/HMCLGameRepository.java | 11 +- .../hmcl/game/HMCLGameRepositoryStatus.java | 52 +++ .../hmcl/game/DefaultGameInstance.java | 17 +- .../hmcl/game/DefaultGameRepository.java | 310 ++-------------- .../game/DefaultGameRepositoryStatus.java | 330 ++++++++++++++++++ .../hmcl/game/GameInstanceManifestTest.java | 8 +- 7 files changed, 432 insertions(+), 308 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryStatus.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryStatus.java 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 291d6b6cae8..20d769aa14a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -68,7 +68,7 @@ public class HMCLGameInstance extends DefaultGameInstance { /// @param status the repository status that owns this instance /// @param id the instance id /// @param manifest the stored instance manifest - protected HMCLGameInstance(DefaultGameRepository.Status status, GameInstanceID id, GameInstanceManifest manifest) { + protected HMCLGameInstance(DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest) { this(status, id, manifest, false); } @@ -77,12 +77,12 @@ protected HMCLGameInstance(DefaultGameRepository.Status status, GameInstanceID i /// @param status the repository status that owns this instance /// @param id the instance id /// @return a provisional instance with an empty placeholder manifest - static HMCLGameInstance provisional(DefaultGameRepository.Status status, GameInstanceID id) { + static HMCLGameInstance provisional(DefaultGameRepositoryStatus status, GameInstanceID id) { return new HMCLGameInstance(status, id, new GameInstanceManifest(id), true); } private HMCLGameInstance( - DefaultGameRepository.Status status, + DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest, boolean provisional) { @@ -95,7 +95,7 @@ private HMCLGameInstance( /// Used when the repository clones a status snapshot or promotes a provisional instance so that /// settings and install-time flags remain available on the new wrapper. private HMCLGameInstance( - DefaultGameRepository.Status status, + DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest, boolean provisional, @@ -110,12 +110,12 @@ private HMCLGameInstance( } @Override - protected HMCLGameInstance withNewStatus(DefaultGameRepository.Status newStatus) { + protected HMCLGameInstance withNewStatus(DefaultGameRepositoryStatus newStatus) { return new HMCLGameInstance(newStatus, id, manifest, provisional, this); } @Override - protected HMCLGameInstance withManifest(DefaultGameRepository.Status newStatus, GameInstanceManifest manifest) { + protected HMCLGameInstance withManifest(DefaultGameRepositoryStatus newStatus, GameInstanceManifest manifest) { // A real stored manifest promotes a provisional placeholder to a registered instance. return new HMCLGameInstance(newStatus, id, manifest, false, this); } 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 358273bee5e..84b3cc65f7f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -99,7 +99,12 @@ protected HMCLGameRepositoryLayout createLayout(Path baseDirectory) { } @Override - protected HMCLGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { + protected HMCLGameRepositoryStatus createStatus(DefaultGameRepositoryLayout layout) { + return new HMCLGameRepositoryStatus(this, (HMCLGameRepositoryLayout) layout); + } + + @Override + protected HMCLGameInstance createInstance(DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest) { DefaultGameInstance existing = status.get(id); if (existing instanceof HMCLGameInstance hmcl) { return hmcl.withManifest(status, manifest); @@ -130,7 +135,7 @@ public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstance /// Returns the instance that owns local state for the given id. /// - /// When the id is already present in the current [Status] (including provisional + /// When the id is already present in the current [DefaultGameRepositoryStatus] (including provisional /// placeholders), that instance is returned. Otherwise a provisional [HMCLGameInstance] is /// created and published in a new status until it is promoted by a real manifest or the /// status is replaced by refresh. @@ -143,7 +148,7 @@ private HMCLGameInstance resolveInstance(GameInstanceID instanceId) { return hmcl; } - Status newStatus = currentStatus().clone(); + DefaultGameRepositoryStatus newStatus = currentStatus().clone(); HMCLGameInstance provisional = HMCLGameInstance.provisional(newStatus, instanceId); newStatus.put(provisional); publishStatus(newStatus); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryStatus.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryStatus.java new file mode 100644 index 00000000000..b7151c844e1 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryStatus.java @@ -0,0 +1,52 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jetbrains.annotations.NotNullByDefault; + +/// HMCL repository status snapshot, parallel to [HMCLGameInstance] in the instance hierarchy. +@NotNullByDefault +public class HMCLGameRepositoryStatus extends DefaultGameRepositoryStatus { + /// Creates an empty unsealed HMCL status. + /// + /// @param repository the owning repository + /// @param layout the HMCL layout for this snapshot + public HMCLGameRepositoryStatus(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) { + super(repository, layout); + } + + @Override + public HMCLGameRepository getRepository() { + return (HMCLGameRepository) super.getRepository(); + } + + @Override + public HMCLGameRepositoryLayout getLayout() { + return (HMCLGameRepositoryLayout) super.getLayout(); + } + + @Override + protected HMCLGameRepositoryStatus newEmpty() { + return new HMCLGameRepositoryStatus(getRepository(), getLayout()); + } + + @Override + public HMCLGameRepositoryStatus clone() { + return (HMCLGameRepositoryStatus) super.clone(); + } +} 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 5f55471b2cf..5da121fae82 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -23,7 +23,6 @@ import java.nio.file.Files; import java.nio.file.Path; -import java.util.HashSet; import java.util.Optional; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -31,7 +30,7 @@ @NotNullByDefault public abstract class DefaultGameInstance implements GameInstance { - protected final DefaultGameRepository.Status status; + protected final DefaultGameRepositoryStatus status; protected final DefaultGameRepository repository; protected final DefaultGameRepositoryLayout layout; protected final GameInstanceID id; @@ -45,24 +44,24 @@ public abstract class DefaultGameInstance implements GameInstance { protected @Nullable GameVersionNumber version; protected DefaultGameInstance( - DefaultGameRepository.Status status, + DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest) { this.status = status; - this.repository = status.repository; - this.layout = status.layout; + this.repository = status.getRepository(); + this.layout = status.getLayout(); this.id = id; this.manifest = manifest; } - protected abstract DefaultGameInstance withNewStatus(DefaultGameRepository.Status newStatus); + protected abstract DefaultGameInstance withNewStatus(DefaultGameRepositoryStatus newStatus); /// Returns a copy of this instance bound to a new status and stored manifest. /// /// @param newStatus the status that will own the copy /// @param manifest the stored instance manifest /// @return the updated instance - protected abstract DefaultGameInstance withManifest(DefaultGameRepository.Status newStatus, GameInstanceManifest manifest); + protected abstract DefaultGameInstance withManifest(DefaultGameRepositoryStatus newStatus, GameInstanceManifest manifest); @Override public DefaultGameRepository getRepository() { @@ -81,7 +80,7 @@ public GameInstanceID getId() { /// Returns whether this instance is only a provisional placeholder. /// - /// Provisional instances may appear in the current [DefaultGameRepository.Status] so that + /// Provisional instances may appear in the current [DefaultGameRepositoryStatus] so that /// instance-local state (for example install-time settings) can be tracked before a real /// manifest is saved. They must not be treated as indexed repository members. /// @@ -98,7 +97,7 @@ public GameInstanceManifest getManifest() { @Override public GameInstanceManifest.Resolved getResolvedManifest() { if (resolvedManifest == null) { - resolvedManifest = status.resolve(manifest, new HashSet<>()); + resolvedManifest = status.resolve(manifest); } return resolvedManifest; } 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 6cf734d94f8..192f514178c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -86,11 +86,11 @@ private static boolean hasClassicVersion(Path baseDirectory) { && Files.exists(bin.resolve("lwjgl_util.jar")); } - private volatile Status status; + private volatile DefaultGameRepositoryStatus status; private volatile boolean loaded; public DefaultGameRepository(Path baseDirectory) { - Status initial = new Status(this, createLayout(baseDirectory)); + DefaultGameRepositoryStatus initial = createStatus(createLayout(baseDirectory)); initial.seal(); this.status = initial; } @@ -102,7 +102,7 @@ public DefaultGameRepository(Path baseDirectory) { protected abstract DefaultGameRepositoryLayout createLayout(Path baseDirectory); public void setBaseDirectory(Path baseDirectory) { - Status initial = new Status(this, createLayout(baseDirectory)); + DefaultGameRepositoryStatus initial = createStatus(createLayout(baseDirectory)); publishStatus(initial); this.loaded = false; } @@ -110,10 +110,10 @@ public void setBaseDirectory(Path baseDirectory) { /// Returns the current published repository status snapshot. /// /// The returned status is sealed and must not be modified. Writers must [#clone()] it, edit the - /// copy, and publish the result with [#publishStatus(Status)]. + /// copy, and publish the result with [#publishStatus(DefaultGameRepositoryStatus)]. /// /// @return the current status - protected Status currentStatus() { + protected DefaultGameRepositoryStatus currentStatus() { return status; } @@ -127,14 +127,14 @@ public GameRepositorySnapshot getSnapshot() { /// /// @param newStatus the status to publish; must not already be visible as [#currentStatus()] /// unless it is a freshly built replacement - protected void publishStatus(Status newStatus) { + protected void publishStatus(DefaultGameRepositoryStatus newStatus) { newStatus.seal(); this.status = newStatus; } @Override public DefaultGameRepositoryLayout getLayout() { - return status.layout; + return status.getLayout(); } public boolean isLoaded() { @@ -153,14 +153,14 @@ public void refresh() { } protected void refreshImpl() { - Status newStatus = new Status(this, status.layout); + DefaultGameRepositoryStatus newStatus = createStatus(status.getLayout()); - if (hasClassicVersion(newStatus.layout.getBaseDirectory())) { + if (hasClassicVersion(newStatus.getLayout().getBaseDirectory())) { GameInstanceID id = CLASSIC_MANIFEST.id(); newStatus.put(createInstance(newStatus, id, CLASSIC_MANIFEST)); } - Path versionsDir = newStatus.layout.getBaseDirectory().resolve("versions"); + Path versionsDir = newStatus.getLayout().getBaseDirectory().resolve("versions"); if (Files.isDirectory(versionsDir)) { try (Stream stream = Files.list(versionsDir)) { stream.parallel().filter(Files::isDirectory).flatMap(dir -> { @@ -220,7 +220,7 @@ protected void refreshImpl() { if (!id.equals(manifest.id())) { try { - moveInstanceFiles(newStatus.layout.getBaseDirectory(), id, manifest.id()); + moveInstanceFiles(newStatus.getLayout().getBaseDirectory(), id, manifest.id()); } catch (IOException e) { LOG.warning("Ignoring instance " + manifest.id() + " because instance id does not match folder name " + id @@ -239,7 +239,7 @@ protected void refreshImpl() { Map loadedInstances = new TreeMap<>(); for (DefaultGameInstance instance : newStatus.values()) { try { - GameInstanceManifest resolved = newStatus.resolve(instance.getManifest(), new HashSet<>()).launchManifest(); + GameInstanceManifest resolved = newStatus.resolve(instance.getManifest()).launchManifest(); if (CompatibilityRule.appliesToCurrentEnvironment(resolved.compatibilityRules())) { loadedInstances.put(instance.getId(), instance); } @@ -326,13 +326,13 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { } try { - Status newStatus = status.clone(); + DefaultGameRepositoryStatus newStatus = status.clone(); DefaultGameInstance fromHolder = newStatus.get(from); if (fromHolder == null || fromHolder.isProvisional()) { throw new NoSuchGameInstanceException(from); } - moveInstanceFiles(newStatus.layout.getBaseDirectory(), from, to); + moveInstanceFiles(newStatus.getLayout().getBaseDirectory(), from, to); GameInstanceManifest renamedManifest = fromHolder.manifest; if (from.equals(renamedManifest.jar())) { @@ -369,7 +369,7 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { } if (status.get(id) != null) { - Status newStatus = status.clone(); + DefaultGameRepositoryStatus newStatus = status.clone(); newStatus.remove(id); publishStatus(newStatus); } @@ -544,7 +544,7 @@ public Task saveAsync(GameInstanceManifest instanceManifes Files.createDirectories(json.getParent()); JsonUtils.writeToJsonFile(json, savedManifest); - Status newStatus = status.clone(); + DefaultGameRepositoryStatus newStatus = status.clone(); DefaultGameInstance existing = newStatus.get(savedManifest.id()); if (existing != null) { newStatus.put(existing.withManifest(newStatus, savedManifest)); @@ -582,280 +582,18 @@ public ResourcePackManager getResourcePackManager(GameInstanceID instanceId) { @Override public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { - return status.resolve(manifest, new HashSet<>()); + return status.resolve(manifest); } - protected abstract DefaultGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest); - - /// Mutable builder and sealed published snapshot of the repository index. - /// - /// A status begins unsealed so that writers can populate it. [#seal()] freezes the instance map; - /// afterwards any mutating method throws. Callers must [#clone()] a published status, edit the - /// copy, and publish it with [DefaultGameRepository#publishStatus(Status)]. + /// Creates an empty unsealed status for the given layout. /// - /// Once sealed, this object is exposed as a [GameRepositorySnapshot]. Provisional placeholders - /// remain reachable through package/internal accessors such as [#get(GameInstanceID)] but are - /// excluded from the public snapshot view. - protected static class Status implements GameRepositorySnapshot { - public final DefaultGameRepository repository; - public final DefaultGameRepositoryLayout layout; - private Map instances; - private boolean sealed; - - /// Creates an empty unsealed status for building a new snapshot. - /// - /// @param repository the owning repository - /// @param layout the layout for this snapshot - protected Status(DefaultGameRepository repository, DefaultGameRepositoryLayout layout) { - this.repository = repository; - this.layout = layout; - this.instances = new TreeMap<>(); - this.sealed = false; - } - - /// Freezes this status so its instance map can no longer be modified. - void seal() { - if (!sealed) { - instances = Collections.unmodifiableMap(new TreeMap<>(instances)); - sealed = true; - } - } - - /// Returns whether this status has been sealed. - /// - /// @return whether mutation is forbidden - public boolean isSealed() { - return sealed; - } - - private void checkMutable() { - if (sealed) { - throw new IllegalStateException("Status has been published and cannot be modified"); - } - } - - /// {@inheritDoc} - @Override - public DefaultGameRepository getRepository() { - return repository; - } - - /// {@inheritDoc} - @Override - public DefaultGameRepositoryLayout getLayout() { - return layout; - } - - /// Returns the instance with the given id, including provisional placeholders. - /// - /// @param id the instance id - /// @return the instance, or `null` when absent - public @Nullable DefaultGameInstance get(GameInstanceID id) { - return instances.get(id); - } - - /// Returns the registered instance with the given id. - /// - /// @param id the instance id - /// @return the registered instance - /// @throws NoSuchGameInstanceException if the instance is absent or provisional - public DefaultGameInstance getRegistered(GameInstanceID id) throws NoSuchGameInstanceException { - DefaultGameInstance instance = instances.get(id); - if (instance != null && !instance.isProvisional()) { - return instance; - } - throw new NoSuchGameInstanceException(id); - } - - /// {@inheritDoc} - @Override - public boolean hasInstance(GameInstanceID instanceId) { - DefaultGameInstance instance = instances.get(instanceId); - return instance != null && !instance.isProvisional(); - } - - /// {@inheritDoc} - @Override - public DefaultGameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getRegistered(instanceId); - } - - /// {@inheritDoc} - @Override - public @Nullable DefaultGameInstance findInstance(GameInstanceID instanceId) { - DefaultGameInstance instance = instances.get(instanceId); - if (instance != null && !instance.isProvisional()) { - return instance; - } - return null; - } - - /// {@inheritDoc} - @Override - public int getInstanceCount() { - int count = 0; - for (DefaultGameInstance instance : instances.values()) { - if (!instance.isProvisional()) { - count++; - } - } - return count; - } - - /// {@inheritDoc} - @Override - public Collection getInstances() { - return instances.values().stream() - .filter(instance -> !instance.isProvisional()) - .toList(); - } - - /// {@inheritDoc} - @Override - public Collection getInstanceManifests() { - return instances.values().stream() - .filter(instance -> !instance.isProvisional()) - .map(instance -> instance.manifest) - .toList(); - } - - /// Returns a view of all instances in this status, including provisional placeholders. - /// - /// @return the instances; unmodifiable after [#seal()] - public Collection values() { - return instances.values(); - } - - /// Returns an unmodifiable map view after seal, or the live map while building. - /// - /// @return the instance map - public Map asMap() { - return instances; - } - - /// Adds or replaces an instance in this unsealed status. - /// - /// @param instance the instance bound to this status - void put(DefaultGameInstance instance) { - checkMutable(); - instances.put(instance.getId(), instance); - } - - /// Adds or replaces all instances from the given map. - /// - /// @param map instances keyed by id - void putAll(Map map) { - checkMutable(); - instances.putAll(map); - } - - /// Removes the instance with the given id. - /// - /// @param id the instance id - void remove(GameInstanceID id) { - checkMutable(); - instances.remove(id); - } - - /// Removes all instances from this unsealed status. - void clear() { - checkMutable(); - instances.clear(); - } - - /// Creates an unsealed copy of this status with instances rebound to the copy. - /// - /// @return a mutable status ready for further edits before publish - @Override - public Status clone() { - Status newStatus = new Status(repository, layout); - for (DefaultGameInstance instance : instances.values()) { - newStatus.instances.put(instance.getId(), instance.withNewStatus(newStatus)); - } - return newStatus; - } - - GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, - Set resolvedSoFar) throws NoSuchGameInstanceException { - GameInstanceManifest launchManifest; - GameInstanceManifest standaloneManifest = manifest.isRoot() - ? manifest - : addPatches( - addPatches(new GameInstanceManifest(manifest.id()), List.of(manifest.toPatch())), - manifest.patches()); - - if (manifest.inheritsFrom() == null) { - if (manifest.isRoot()) { - // TODO: Breaking change, require much testing on versions installed with external installer, other launchers, and all kinds of versions. - launchManifest = manifest.patches() != null ? new GameInstanceManifest(manifest.id()).withPatches(manifest.patches()) : manifest; - } else { - launchManifest = manifest; - } - launchManifest = launchManifest.withJar(manifest.jar() == null ? manifest.id() : manifest.jar()); - } else { - // To maximize the compatibility. - if (!resolvedSoFar.add(manifest.id())) { - LOG.warning("Found circular dependency versions: " + resolvedSoFar); - launchManifest = (manifest.jar() == null ? manifest.withJar(manifest.id()) : manifest) - .withInheritsFrom(null); - } else { - DefaultGameInstance parentInstance = instances.get(manifest.inheritsFrom()); - if (parentInstance == null) { - throw new NoSuchGameInstanceException(manifest.inheritsFrom()); - } - - // It is supposed to auto-install a version in getVersion. - GameInstanceManifest.Resolved parentResolved = resolve(parentInstance.getManifest(), resolvedSoFar); - launchManifest = manifest.merge(parentResolved.launchManifest()); - standaloneManifest = addPatches( - addPatches(parentResolved.standaloneManifest(), Collections.singleton(manifest.toPatch())), - manifest.patches()); - } - } - - if (manifest.patches() != null && !manifest.patches().isEmpty()) { - // Assume patches themselves do not have patches recursively. - List sortedPatches = manifest.patches().stream() - .sorted(Comparator.comparing(GameInstancePatch::getPriority)) - .toList(); - for (GameInstancePatch patch : sortedPatches) { - launchManifest = patch.merge(launchManifest); - } - } - - launchManifest = launchManifest.withId(manifest.id()).withPatches(null); - standaloneManifest = standaloneManifest.withId(manifest.id()); - if (launchManifest.jar() != null) { - standaloneManifest = standaloneManifest.withJar(launchManifest.jar()); - } - - return new GameInstanceManifest.Resolved(manifest, launchManifest, standaloneManifest); - } - - private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @Nullable Collection additional) { - if (additional == null || additional.isEmpty()) { - return manifest; - } + /// @param layout the layout for the new status + /// @return a new unsealed status + protected DefaultGameRepositoryStatus createStatus(DefaultGameRepositoryLayout layout) { + return new DefaultGameRepositoryStatus(this, layout); + } - Set patchIds = new HashSet<>(); - for (GameInstancePatch patch : additional) { - if (patch.id() != null) { - patchIds.add(patch.id()); - } - } + protected abstract DefaultGameInstance createInstance(DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest); - 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/DefaultGameRepositoryStatus.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryStatus.java new file mode 100644 index 00000000000..a07fee11077 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryStatus.java @@ -0,0 +1,330 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Default implementation of a repository index snapshot for [DefaultGameRepository]. +/// +/// A status begins unsealed so writers can populate it. [#seal()] freezes the instance map; +/// afterwards any mutating method throws. Callers must [#clone()] a published status, edit the +/// copy, and publish it with [DefaultGameRepository#publishStatus(DefaultGameRepositoryStatus)]. +/// +/// Once sealed, this object is exposed as a [GameRepositorySnapshot]. Provisional placeholders +/// remain reachable through [#get(GameInstanceID)] but are excluded from the public snapshot view. +/// +/// Subclasses such as HMCL-specific statuses may override [#newEmpty()] to preserve concrete type +/// through [#clone()], analogous to [DefaultGameInstance#withNewStatus(DefaultGameRepositoryStatus)]. +@NotNullByDefault +public class DefaultGameRepositoryStatus implements GameRepositorySnapshot { + protected final DefaultGameRepository repository; + protected final DefaultGameRepositoryLayout layout; + private Map instances; + private boolean sealed; + + /// Creates an empty unsealed status for building a new snapshot. + /// + /// @param repository the owning repository + /// @param layout the layout for this snapshot + public DefaultGameRepositoryStatus(DefaultGameRepository repository, DefaultGameRepositoryLayout layout) { + this.repository = repository; + this.layout = layout; + this.instances = new TreeMap<>(); + this.sealed = false; + } + + /// Creates an empty unsealed status of the same concrete type as this status. + /// + /// @return a new empty unsealed status + protected DefaultGameRepositoryStatus newEmpty() { + return new DefaultGameRepositoryStatus(repository, layout); + } + + /// Freezes this status so its instance map can no longer be modified. + public void seal() { + if (!sealed) { + instances = Collections.unmodifiableMap(new TreeMap<>(instances)); + sealed = true; + } + } + + /// Returns whether this status has been sealed. + /// + /// @return whether mutation is forbidden + public boolean isSealed() { + return sealed; + } + + private void checkMutable() { + if (sealed) { + throw new IllegalStateException("Status has been published and cannot be modified"); + } + } + + /// {@inheritDoc} + @Override + public DefaultGameRepository getRepository() { + return repository; + } + + /// {@inheritDoc} + @Override + public DefaultGameRepositoryLayout getLayout() { + return layout; + } + + /// Returns the instance with the given id, including provisional placeholders. + /// + /// @param id the instance id + /// @return the instance, or `null` when absent + public @Nullable DefaultGameInstance get(GameInstanceID id) { + return instances.get(id); + } + + /// Returns the registered instance with the given id. + /// + /// @param id the instance id + /// @return the registered instance + /// @throws NoSuchGameInstanceException if the instance is absent or provisional + public DefaultGameInstance getRegistered(GameInstanceID id) throws NoSuchGameInstanceException { + DefaultGameInstance instance = instances.get(id); + if (instance != null && !instance.isProvisional()) { + return instance; + } + throw new NoSuchGameInstanceException(id); + } + + /// {@inheritDoc} + @Override + public boolean hasInstance(GameInstanceID instanceId) { + DefaultGameInstance instance = instances.get(instanceId); + return instance != null && !instance.isProvisional(); + } + + /// {@inheritDoc} + @Override + public DefaultGameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException { + return getRegistered(instanceId); + } + + /// {@inheritDoc} + @Override + public @Nullable DefaultGameInstance findInstance(GameInstanceID instanceId) { + DefaultGameInstance instance = instances.get(instanceId); + if (instance != null && !instance.isProvisional()) { + return instance; + } + return null; + } + + /// {@inheritDoc} + @Override + public int getInstanceCount() { + int count = 0; + for (DefaultGameInstance instance : instances.values()) { + if (!instance.isProvisional()) { + count++; + } + } + return count; + } + + /// {@inheritDoc} + @Override + public Collection getInstances() { + return instances.values().stream() + .filter(instance -> !instance.isProvisional()) + .toList(); + } + + /// {@inheritDoc} + @Override + public Collection getInstanceManifests() { + return instances.values().stream() + .filter(instance -> !instance.isProvisional()) + .map(instance -> instance.manifest) + .toList(); + } + + /// Returns a view of all instances in this status, including provisional placeholders. + /// + /// @return the instances; unmodifiable after [#seal()] + public Collection values() { + return instances.values(); + } + + /// Returns an unmodifiable map view after seal, or the live map while building. + /// + /// @return the instance map + public Map asMap() { + return instances; + } + + /// Adds or replaces an instance in this unsealed status. + /// + /// @param instance the instance bound to this status + public void put(DefaultGameInstance instance) { + checkMutable(); + instances.put(instance.getId(), instance); + } + + /// Adds or replaces all instances from the given map. + /// + /// @param map instances keyed by id + public void putAll(Map map) { + checkMutable(); + instances.putAll(map); + } + + /// Removes the instance with the given id. + /// + /// @param id the instance id + public void remove(GameInstanceID id) { + checkMutable(); + instances.remove(id); + } + + /// Removes all instances from this unsealed status. + public void clear() { + checkMutable(); + instances.clear(); + } + + /// Creates an unsealed copy of this status with instances rebound to the copy. + /// + /// @return a mutable status ready for further edits before publish + @Override + public DefaultGameRepositoryStatus clone() { + DefaultGameRepositoryStatus newStatus = newEmpty(); + for (DefaultGameInstance instance : instances.values()) { + newStatus.put(instance.withNewStatus(newStatus)); + } + return newStatus; + } + + /// Resolves official-layout inheritance and patches into launch and standalone views. + /// + /// @param manifest the manifest to resolve + /// @return the resolved manifest views + /// @throws NoSuchGameInstanceException if an inherited parent is missing from this status + public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { + return resolve(manifest, new HashSet<>()); + } + + /// Resolves official-layout inheritance and patches into launch and standalone views. + /// + /// @param manifest the manifest to resolve + /// @param resolvedSoFar instance ids already visited in the inheritance chain + /// @return the resolved manifest views + /// @throws NoSuchGameInstanceException if an inherited parent is missing from this status + public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, + Set resolvedSoFar) throws NoSuchGameInstanceException { + GameInstanceManifest launchManifest; + GameInstanceManifest standaloneManifest = manifest.isRoot() + ? manifest + : addPatches( + addPatches(new GameInstanceManifest(manifest.id()), List.of(manifest.toPatch())), + manifest.patches()); + + if (manifest.inheritsFrom() == null) { + if (manifest.isRoot()) { + // TODO: Breaking change, require much testing on versions installed with external installer, other launchers, and all kinds of versions. + launchManifest = manifest.patches() != null + ? new GameInstanceManifest(manifest.id()).withPatches(manifest.patches()) + : manifest; + } else { + launchManifest = manifest; + } + launchManifest = launchManifest.withJar(manifest.jar() == null ? manifest.id() : manifest.jar()); + } else { + // To maximize the compatibility. + if (!resolvedSoFar.add(manifest.id())) { + LOG.warning("Found circular dependency versions: " + resolvedSoFar); + launchManifest = (manifest.jar() == null ? manifest.withJar(manifest.id()) : manifest) + .withInheritsFrom(null); + } else { + DefaultGameInstance parentInstance = instances.get(manifest.inheritsFrom()); + if (parentInstance == null) { + throw new NoSuchGameInstanceException(manifest.inheritsFrom()); + } + + // It is supposed to auto-install a version in getVersion. + GameInstanceManifest.Resolved parentResolved = resolve(parentInstance.getManifest(), resolvedSoFar); + launchManifest = manifest.merge(parentResolved.launchManifest()); + standaloneManifest = addPatches( + addPatches(parentResolved.standaloneManifest(), Collections.singleton(manifest.toPatch())), + manifest.patches()); + } + } + + if (manifest.patches() != null && !manifest.patches().isEmpty()) { + // Assume patches themselves do not have patches recursively. + List sortedPatches = manifest.patches().stream() + .sorted(Comparator.comparing(GameInstancePatch::getPriority)) + .toList(); + for (GameInstancePatch patch : sortedPatches) { + launchManifest = patch.merge(launchManifest); + } + } + + launchManifest = launchManifest.withId(manifest.id()).withPatches(null); + standaloneManifest = standaloneManifest.withId(manifest.id()); + if (launchManifest.jar() != null) { + standaloneManifest = standaloneManifest.withJar(launchManifest.jar()); + } + + return new GameInstanceManifest.Resolved(manifest, launchManifest, standaloneManifest); + } + + private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @Nullable Collection additional) { + if (additional == null || additional.isEmpty()) { + return manifest; + } + + Set patchIds = new HashSet<>(); + for (GameInstancePatch patch : additional) { + if (patch.id() != null) { + patchIds.add(patch.id()); + } + } + + List patches = new ArrayList<>(); + if (manifest.patches() != null) { + for (GameInstancePatch patch : manifest.patches()) { + if (patch.id() == null || !patchIds.contains(patch.id())) { + patches.add(patch); + } + } + } + patches.addAll(additional); + return manifest.withPatches(patches); + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java index 245520236de..ede33f78250 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java @@ -61,19 +61,19 @@ protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { } @Override - protected DefaultGameInstance createInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { + protected DefaultGameInstance createInstance(DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest) { final class MyGameInstance extends DefaultGameInstance { - MyGameInstance(Status status, GameInstanceID id, GameInstanceManifest manifest) { + MyGameInstance(DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest) { super(status, id, manifest); } @Override - protected DefaultGameInstance withNewStatus(Status newStatus) { + protected DefaultGameInstance withNewStatus(DefaultGameRepositoryStatus newStatus) { return new MyGameInstance(newStatus, id, manifest); } @Override - protected DefaultGameInstance withManifest(Status newStatus, GameInstanceManifest manifest) { + protected DefaultGameInstance withManifest(DefaultGameRepositoryStatus newStatus, GameInstanceManifest manifest) { return new MyGameInstance(newStatus, id, manifest); } } From f7c747971d87f2745f5d6f369544859e1d7f2024 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 02:03:22 +0800 Subject: [PATCH 020/199] Rename DefaultGameRepositoryStatus to DefaultGameRepositorySnapshot for clarity and consistency --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 34 ++--- .../hmcl/game/HMCLGameRepository.java | 30 ++--- ...s.java => HMCLGameRepositorySnapshot.java} | 16 +-- .../hmcl/game/DefaultGameInstance.java | 24 ++-- .../hmcl/game/DefaultGameRepository.java | 121 +++++++++--------- ...ava => DefaultGameRepositorySnapshot.java} | 54 ++++---- .../hmcl/game/GameInstanceManifestTest.java | 16 +-- 7 files changed, 147 insertions(+), 148 deletions(-) rename HMCL/src/main/java/org/jackhuang/hmcl/game/{HMCLGameRepositoryStatus.java => HMCLGameRepositorySnapshot.java} (70%) rename HMCLCore/src/main/java/org/jackhuang/hmcl/game/{DefaultGameRepositoryStatus.java => DefaultGameRepositorySnapshot.java} (85%) 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 20d769aa14a..e33f7896cf2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -47,7 +47,7 @@ @NotNullByDefault public class HMCLGameInstance extends DefaultGameInstance { - /// Whether this instance is only a provisional placeholder in the current status. + /// Whether this instance is only a provisional placeholder in the current snapshot. private final boolean provisional; /// Whether install-time code currently treats this instance as a modpack for run-directory @@ -63,44 +63,44 @@ public class HMCLGameInstance extends DefaultGameInstance { /// Cached instance-local game settings, or `null` when none exist after loading. private GameSettings.@Nullable Instance gameSettings; - /// Creates a registered instance bound to the given repository status snapshot. + /// Creates a registered instance bound to the given repository snapshot. /// - /// @param status the repository status that owns this instance + /// @param snapshot the repository snapshot that owns this instance /// @param id the instance id /// @param manifest the stored instance manifest - protected HMCLGameInstance(DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest) { - this(status, id, manifest, false); + protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { + this(snapshot, id, manifest, false); } /// Creates a provisional instance used before a real manifest is indexed. /// - /// @param status the repository status that owns this instance + /// @param snapshot the repository snapshot that owns this instance /// @param id the instance id /// @return a provisional instance with an empty placeholder manifest - static HMCLGameInstance provisional(DefaultGameRepositoryStatus status, GameInstanceID id) { - return new HMCLGameInstance(status, id, new GameInstanceManifest(id), true); + static HMCLGameInstance provisional(DefaultGameRepositorySnapshot snapshot, GameInstanceID id) { + return new HMCLGameInstance(snapshot, id, new GameInstanceManifest(id), true); } private HMCLGameInstance( - DefaultGameRepositoryStatus status, + DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, boolean provisional) { - super(status, id, manifest); + super(snapshot, id, manifest); this.provisional = provisional; } /// Creates an instance that shares mutable instance-local state with another instance. /// - /// Used when the repository clones a status snapshot or promotes a provisional instance so that + /// Used when the repository clones a snapshot or promotes a provisional instance so that /// settings and install-time flags remain available on the new wrapper. private HMCLGameInstance( - DefaultGameRepositoryStatus status, + DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, boolean provisional, HMCLGameInstance shareState) { - super(status, id, manifest); + super(snapshot, id, manifest); this.provisional = provisional; this.treatingAsModpack = shareState.treatingAsModpack; this.gameSettingsLoaded = shareState.gameSettingsLoaded; @@ -110,14 +110,14 @@ private HMCLGameInstance( } @Override - protected HMCLGameInstance withNewStatus(DefaultGameRepositoryStatus newStatus) { - return new HMCLGameInstance(newStatus, id, manifest, provisional, this); + protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) { + return new HMCLGameInstance(newSnapshot, id, manifest, provisional, this); } @Override - protected HMCLGameInstance withManifest(DefaultGameRepositoryStatus newStatus, GameInstanceManifest manifest) { + protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) { // A real stored manifest promotes a provisional placeholder to a registered instance. - return new HMCLGameInstance(newStatus, id, manifest, false, this); + return new HMCLGameInstance(newSnapshot, id, manifest, false, this); } @Override 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 84b3cc65f7f..0e6375f3872 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -99,17 +99,17 @@ protected HMCLGameRepositoryLayout createLayout(Path baseDirectory) { } @Override - protected HMCLGameRepositoryStatus createStatus(DefaultGameRepositoryLayout layout) { - return new HMCLGameRepositoryStatus(this, (HMCLGameRepositoryLayout) layout); + protected HMCLGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout layout) { + return new HMCLGameRepositorySnapshot(this, (HMCLGameRepositoryLayout) layout); } @Override - protected HMCLGameInstance createInstance(DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest) { - DefaultGameInstance existing = status.get(id); + protected HMCLGameInstance createInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { + DefaultGameInstance existing = snapshot.get(id); if (existing instanceof HMCLGameInstance hmcl) { - return hmcl.withManifest(status, manifest); + return hmcl.withManifest(snapshot, manifest); } - return new HMCLGameInstance(status, id, manifest); + return new HMCLGameInstance(snapshot, id, manifest); } @Override @@ -135,23 +135,23 @@ public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstance /// Returns the instance that owns local state for the given id. /// - /// When the id is already present in the current [DefaultGameRepositoryStatus] (including provisional + /// When the id is already present in the current [DefaultGameRepositorySnapshot] (including provisional /// placeholders), that instance is returned. Otherwise a provisional [HMCLGameInstance] is - /// created and published in a new status until it is promoted by a real manifest or the - /// status is replaced by refresh. + /// created and published in a new snapshot until it is promoted by a real manifest or the + /// snapshot is replaced by refresh. /// /// @param instanceId the instance id /// @return the instance used to manage settings and install-time state for the id private HMCLGameInstance resolveInstance(GameInstanceID instanceId) { - DefaultGameInstance existing = findStatusInstance(instanceId); + DefaultGameInstance existing = findSnapshotInstance(instanceId); if (existing instanceof HMCLGameInstance hmcl) { return hmcl; } - DefaultGameRepositoryStatus newStatus = currentStatus().clone(); - HMCLGameInstance provisional = HMCLGameInstance.provisional(newStatus, instanceId); - newStatus.put(provisional); - publishStatus(newStatus); + DefaultGameRepositorySnapshot newSnapshot = currentSnapshot().clone(); + HMCLGameInstance provisional = HMCLGameInstance.provisional(newSnapshot, instanceId); + newSnapshot.put(provisional); + publishSnapshot(newSnapshot); return provisional; } @@ -596,7 +596,7 @@ public void markInstanceAsModpack(GameInstanceID instanceId) { /// /// @param instanceId the instance id public void undoMark(GameInstanceID instanceId) { - DefaultGameInstance existing = findStatusInstance(instanceId); + DefaultGameInstance existing = findSnapshotInstance(instanceId); if (existing instanceof HMCLGameInstance hmcl) { hmcl.unmarkAsModpack(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryStatus.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java similarity index 70% rename from HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryStatus.java rename to HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java index b7151c844e1..95fcc84e055 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositoryStatus.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java @@ -19,14 +19,14 @@ import org.jetbrains.annotations.NotNullByDefault; -/// HMCL repository status snapshot, parallel to [HMCLGameInstance] in the instance hierarchy. +/// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy. @NotNullByDefault -public class HMCLGameRepositoryStatus extends DefaultGameRepositoryStatus { - /// Creates an empty unsealed HMCL status. +public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot { + /// Creates an empty unsealed HMCL snapshot. /// /// @param repository the owning repository /// @param layout the HMCL layout for this snapshot - public HMCLGameRepositoryStatus(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) { + public HMCLGameRepositorySnapshot(HMCLGameRepository repository, HMCLGameRepositoryLayout layout) { super(repository, layout); } @@ -41,12 +41,12 @@ public HMCLGameRepositoryLayout getLayout() { } @Override - protected HMCLGameRepositoryStatus newEmpty() { - return new HMCLGameRepositoryStatus(getRepository(), getLayout()); + protected HMCLGameRepositorySnapshot newEmpty() { + return new HMCLGameRepositorySnapshot(getRepository(), getLayout()); } @Override - public HMCLGameRepositoryStatus clone() { - return (HMCLGameRepositoryStatus) super.clone(); + public HMCLGameRepositorySnapshot clone() { + return (HMCLGameRepositorySnapshot) super.clone(); } } 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 5da121fae82..f03d7e02b0b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -30,7 +30,7 @@ @NotNullByDefault public abstract class DefaultGameInstance implements GameInstance { - protected final DefaultGameRepositoryStatus status; + protected final DefaultGameRepositorySnapshot snapshot; protected final DefaultGameRepository repository; protected final DefaultGameRepositoryLayout layout; protected final GameInstanceID id; @@ -44,24 +44,24 @@ public abstract class DefaultGameInstance implements GameInstance { protected @Nullable GameVersionNumber version; protected DefaultGameInstance( - DefaultGameRepositoryStatus status, + DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { - this.status = status; - this.repository = status.getRepository(); - this.layout = status.getLayout(); + this.snapshot = snapshot; + this.repository = snapshot.getRepository(); + this.layout = snapshot.getLayout(); this.id = id; this.manifest = manifest; } - protected abstract DefaultGameInstance withNewStatus(DefaultGameRepositoryStatus newStatus); + protected abstract DefaultGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot); - /// Returns a copy of this instance bound to a new status and stored manifest. + /// Returns a copy of this instance bound to a new snapshot and stored manifest. /// - /// @param newStatus the status that will own the copy - /// @param manifest the stored instance manifest + /// @param newSnapshot the snapshot that will own the copy + /// @param manifest the stored instance manifest /// @return the updated instance - protected abstract DefaultGameInstance withManifest(DefaultGameRepositoryStatus newStatus, GameInstanceManifest manifest); + protected abstract DefaultGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest); @Override public DefaultGameRepository getRepository() { @@ -80,7 +80,7 @@ public GameInstanceID getId() { /// Returns whether this instance is only a provisional placeholder. /// - /// Provisional instances may appear in the current [DefaultGameRepositoryStatus] so that + /// Provisional instances may appear in the current [DefaultGameRepositorySnapshot] so that /// instance-local state (for example install-time settings) can be tracked before a real /// manifest is saved. They must not be treated as indexed repository members. /// @@ -97,7 +97,7 @@ public GameInstanceManifest getManifest() { @Override public GameInstanceManifest.Resolved getResolvedManifest() { if (resolvedManifest == null) { - resolvedManifest = status.resolve(manifest); + resolvedManifest = snapshot.resolve(manifest); } return resolvedManifest; } 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 192f514178c..207695d98e0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -86,13 +86,13 @@ private static boolean hasClassicVersion(Path baseDirectory) { && Files.exists(bin.resolve("lwjgl_util.jar")); } - private volatile DefaultGameRepositoryStatus status; + private volatile DefaultGameRepositorySnapshot snapshot; private volatile boolean loaded; public DefaultGameRepository(Path baseDirectory) { - DefaultGameRepositoryStatus initial = createStatus(createLayout(baseDirectory)); + DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); initial.seal(); - this.status = initial; + this.snapshot = initial; } /// Creates the repository layout rooted at the given directory. @@ -102,39 +102,39 @@ public DefaultGameRepository(Path baseDirectory) { protected abstract DefaultGameRepositoryLayout createLayout(Path baseDirectory); public void setBaseDirectory(Path baseDirectory) { - DefaultGameRepositoryStatus initial = createStatus(createLayout(baseDirectory)); - publishStatus(initial); + DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); + publishSnapshot(initial); this.loaded = false; } - /// Returns the current published repository status snapshot. + /// Returns the current published repository snapshot. /// - /// The returned status is sealed and must not be modified. Writers must [#clone()] it, edit the - /// copy, and publish the result with [#publishStatus(DefaultGameRepositoryStatus)]. + /// The returned snapshot is sealed and must not be modified. Writers must [#clone()] it, edit the + /// copy, and publish the result with [#publishSnapshot(DefaultGameRepositorySnapshot)]. /// - /// @return the current status - protected DefaultGameRepositoryStatus currentStatus() { - return status; + /// @return the current snapshot + protected DefaultGameRepositorySnapshot currentSnapshot() { + return snapshot; } /// {@inheritDoc} @Override public GameRepositorySnapshot getSnapshot() { - return status; + return snapshot; } - /// Seals `newStatus` if needed and publishes it as the current repository snapshot. + /// Seals `newSnapshot` if needed and publishes it as the current repository snapshot. /// - /// @param newStatus the status to publish; must not already be visible as [#currentStatus()] - /// unless it is a freshly built replacement - protected void publishStatus(DefaultGameRepositoryStatus newStatus) { - newStatus.seal(); - this.status = newStatus; + /// @param newSnapshot the snapshot to publish; must not already be visible as [#currentSnapshot()] + /// unless it is a freshly built replacement + protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { + newSnapshot.seal(); + this.snapshot = newSnapshot; } @Override public DefaultGameRepositoryLayout getLayout() { - return status.getLayout(); + return snapshot.getLayout(); } public boolean isLoaded() { @@ -153,14 +153,14 @@ public void refresh() { } protected void refreshImpl() { - DefaultGameRepositoryStatus newStatus = createStatus(status.getLayout()); + DefaultGameRepositorySnapshot newSnapshot = createSnapshot(snapshot.getLayout()); - if (hasClassicVersion(newStatus.getLayout().getBaseDirectory())) { + if (hasClassicVersion(newSnapshot.getLayout().getBaseDirectory())) { GameInstanceID id = CLASSIC_MANIFEST.id(); - newStatus.put(createInstance(newStatus, id, CLASSIC_MANIFEST)); + newSnapshot.put(createInstance(newSnapshot, id, CLASSIC_MANIFEST)); } - Path versionsDir = newStatus.getLayout().getBaseDirectory().resolve("versions"); + Path versionsDir = newSnapshot.getLayout().getBaseDirectory().resolve("versions"); if (Files.isDirectory(versionsDir)) { try (Stream stream = Files.list(versionsDir)) { stream.parallel().filter(Files::isDirectory).flatMap(dir -> { @@ -220,7 +220,7 @@ protected void refreshImpl() { if (!id.equals(manifest.id())) { try { - moveInstanceFiles(newStatus.getLayout().getBaseDirectory(), id, manifest.id()); + moveInstanceFiles(newSnapshot.getLayout().getBaseDirectory(), id, manifest.id()); } catch (IOException e) { LOG.warning("Ignoring instance " + manifest.id() + " because instance id does not match folder name " + id @@ -230,16 +230,16 @@ protected void refreshImpl() { } return Stream.of(manifest); - }).forEachOrdered(it -> newStatus.put(createInstance(newStatus, it.id(), it))); + }).forEachOrdered(it -> newSnapshot.put(createInstance(newSnapshot, it.id(), it))); } catch (IOException e) { LOG.warning("Failed to load versions from " + versionsDir, e); } } Map loadedInstances = new TreeMap<>(); - for (DefaultGameInstance instance : newStatus.values()) { + for (DefaultGameInstance instance : newSnapshot.values()) { try { - GameInstanceManifest resolved = newStatus.resolve(instance.getManifest()).launchManifest(); + GameInstanceManifest resolved = newSnapshot.resolve(instance.getManifest()).launchManifest(); if (CompatibilityRule.appliesToCurrentEnvironment(resolved.compatibilityRules())) { loadedInstances.put(instance.getId(), instance); } @@ -248,9 +248,9 @@ protected void refreshImpl() { } } - newStatus.clear(); - newStatus.putAll(loadedInstances); - publishStatus(newStatus); + newSnapshot.clear(); + newSnapshot.putAll(loadedInstances); + publishSnapshot(newSnapshot); } private static GameInstanceManifest readInstanceManifest(Path json) throws IOException, JsonParseException { @@ -291,16 +291,16 @@ private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, G @Override public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { - return status.getRegistered(id); + return snapshot.getRegistered(id); } - /// Returns the instance recorded in the current status for the given id, including provisional + /// Returns the instance recorded in the current snapshot for the given id, including provisional /// placeholders. /// /// @param id the instance id - /// @return the instance, or `null` when absent from the current status - protected @Nullable DefaultGameInstance findStatusInstance(GameInstanceID id) { - return status.get(id); + /// @return the instance, or `null` when absent from the current snapshot + protected @Nullable DefaultGameInstance findSnapshotInstance(GameInstanceID id) { + return snapshot.get(id); } public Path getArtifactFile(GameInstanceManifest manifest, Artifact artifact) { @@ -326,13 +326,13 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { } try { - DefaultGameRepositoryStatus newStatus = status.clone(); - DefaultGameInstance fromHolder = newStatus.get(from); + DefaultGameRepositorySnapshot newSnapshot = snapshot.clone(); + DefaultGameInstance fromHolder = newSnapshot.get(from); if (fromHolder == null || fromHolder.isProvisional()) { throw new NoSuchGameInstanceException(from); } - moveInstanceFiles(newStatus.getLayout().getBaseDirectory(), from, to); + moveInstanceFiles(newSnapshot.getLayout().getBaseDirectory(), from, to); GameInstanceManifest renamedManifest = fromHolder.manifest; if (from.equals(renamedManifest.jar())) { @@ -341,21 +341,21 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { renamedManifest = renamedManifest.withId(to); JsonUtils.writeToJsonFile(getInstanceJson(to), renamedManifest); - newStatus.remove(from); - newStatus.put(fromHolder.withManifest(newStatus, renamedManifest)); + newSnapshot.remove(from); + newSnapshot.put(fromHolder.withManifest(newSnapshot, renamedManifest)); - for (DefaultGameInstance instance : List.copyOf(newStatus.values())) { + for (DefaultGameInstance instance : List.copyOf(newSnapshot.values())) { GameInstanceManifest manifest = instance.manifest; if (from.equals(manifest.inheritsFrom())) { GameInstanceManifest updatedManifest = manifest.withInheritsFrom(to); Path targetPath = getInstanceJson(updatedManifest.id()); Files.createDirectories(targetPath.getParent()); JsonUtils.writeToJsonFile(targetPath, updatedManifest); - newStatus.put(instance.withManifest(newStatus, updatedManifest)); + newSnapshot.put(instance.withManifest(newSnapshot, updatedManifest)); } } - publishStatus(newStatus); + publishSnapshot(newSnapshot); return true; } catch (IOException | JsonParseException | NoSuchGameInstanceException | InvalidPathException e) { LOG.warning("Unable to rename version " + from + " to " + to, e); @@ -368,10 +368,10 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { return false; } - if (status.get(id) != null) { - DefaultGameRepositoryStatus newStatus = status.clone(); - newStatus.remove(id); - publishStatus(newStatus); + if (snapshot.get(id) != null) { + DefaultGameRepositorySnapshot newSnapshot = snapshot.clone(); + newSnapshot.remove(id); + publishSnapshot(newSnapshot); } Path file = getLayout().getInstanceRoot(id); @@ -422,7 +422,7 @@ public Optional getGameVersion(GameInstanceID instanceId) throws NoSuchG @Override public Optional getGameVersion(GameInstanceManifest manifest) { - DefaultGameInstance instance = findStatusInstance(manifest.id()); + DefaultGameInstance instance = findSnapshotInstance(manifest.id()); if (instance != null && !instance.isProvisional()) { GameVersionNumber version = instance.getVersion(); if (version == GameVersionNumber.unknown()) { @@ -544,14 +544,14 @@ public Task saveAsync(GameInstanceManifest instanceManifes Files.createDirectories(json.getParent()); JsonUtils.writeToJsonFile(json, savedManifest); - DefaultGameRepositoryStatus newStatus = status.clone(); - DefaultGameInstance existing = newStatus.get(savedManifest.id()); + DefaultGameRepositorySnapshot newSnapshot = snapshot.clone(); + DefaultGameInstance existing = newSnapshot.get(savedManifest.id()); if (existing != null) { - newStatus.put(existing.withManifest(newStatus, savedManifest)); + newSnapshot.put(existing.withManifest(newSnapshot, savedManifest)); } else { - newStatus.put(createInstance(newStatus, savedManifest.id(), savedManifest)); + newSnapshot.put(createInstance(newSnapshot, savedManifest.id(), savedManifest)); } - publishStatus(newStatus); + publishSnapshot(newSnapshot); return savedManifest; }); } @@ -582,18 +582,17 @@ public ResourcePackManager getResourcePackManager(GameInstanceID instanceId) { @Override public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { - return status.resolve(manifest); + return snapshot.resolve(manifest); } - /// Creates an empty unsealed status for the given layout. + /// Creates an empty unsealed snapshot for the given layout. /// - /// @param layout the layout for the new status - /// @return a new unsealed status - protected DefaultGameRepositoryStatus createStatus(DefaultGameRepositoryLayout layout) { - return new DefaultGameRepositoryStatus(this, layout); + /// @param layout the layout for the new snapshot + /// @return a new unsealed snapshot + protected DefaultGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout layout) { + return new DefaultGameRepositorySnapshot(this, layout); } - protected abstract DefaultGameInstance createInstance(DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest); - + protected abstract DefaultGameInstance createInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryStatus.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java similarity index 85% rename from HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryStatus.java rename to HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java index a07fee11077..9d637abb63c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryStatus.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -34,41 +34,41 @@ /// Default implementation of a repository index snapshot for [DefaultGameRepository]. /// -/// A status begins unsealed so writers can populate it. [#seal()] freezes the instance map; -/// afterwards any mutating method throws. Callers must [#clone()] a published status, edit the -/// copy, and publish it with [DefaultGameRepository#publishStatus(DefaultGameRepositoryStatus)]. +/// A snapshot begins unsealed so writers can populate it. [#seal()] freezes the instance map; +/// afterwards any mutating method throws. Callers must [#clone()] a published snapshot, edit the +/// copy, and publish it with [DefaultGameRepository#publishSnapshot(DefaultGameRepositorySnapshot)]. /// /// Once sealed, this object is exposed as a [GameRepositorySnapshot]. Provisional placeholders /// remain reachable through [#get(GameInstanceID)] but are excluded from the public snapshot view. /// -/// Subclasses such as HMCL-specific statuses may override [#newEmpty()] to preserve concrete type -/// through [#clone()], analogous to [DefaultGameInstance#withNewStatus(DefaultGameRepositoryStatus)]. +/// Subclasses such as HMCL-specific snapshots may override [#newEmpty()] to preserve concrete type +/// through [#clone()], analogous to [DefaultGameInstance#withNewSnapshot(DefaultGameRepositorySnapshot)]. @NotNullByDefault -public class DefaultGameRepositoryStatus implements GameRepositorySnapshot { +public class DefaultGameRepositorySnapshot implements GameRepositorySnapshot { protected final DefaultGameRepository repository; protected final DefaultGameRepositoryLayout layout; private Map instances; private boolean sealed; - /// Creates an empty unsealed status for building a new snapshot. + /// Creates an empty unsealed snapshot for building a new snapshot. /// /// @param repository the owning repository /// @param layout the layout for this snapshot - public DefaultGameRepositoryStatus(DefaultGameRepository repository, DefaultGameRepositoryLayout layout) { + public DefaultGameRepositorySnapshot(DefaultGameRepository repository, DefaultGameRepositoryLayout layout) { this.repository = repository; this.layout = layout; this.instances = new TreeMap<>(); this.sealed = false; } - /// Creates an empty unsealed status of the same concrete type as this status. + /// Creates an empty unsealed snapshot of the same concrete type as this snapshot. /// - /// @return a new empty unsealed status - protected DefaultGameRepositoryStatus newEmpty() { - return new DefaultGameRepositoryStatus(repository, layout); + /// @return a new empty unsealed snapshot + protected DefaultGameRepositorySnapshot newEmpty() { + return new DefaultGameRepositorySnapshot(repository, layout); } - /// Freezes this status so its instance map can no longer be modified. + /// Freezes this snapshot so its instance map can no longer be modified. public void seal() { if (!sealed) { instances = Collections.unmodifiableMap(new TreeMap<>(instances)); @@ -76,7 +76,7 @@ public void seal() { } } - /// Returns whether this status has been sealed. + /// Returns whether this snapshot has been sealed. /// /// @return whether mutation is forbidden public boolean isSealed() { @@ -85,7 +85,7 @@ public boolean isSealed() { private void checkMutable() { if (sealed) { - throw new IllegalStateException("Status has been published and cannot be modified"); + throw new IllegalStateException("Snapshot has been published and cannot be modified"); } } @@ -174,7 +174,7 @@ public Collection getInstanceManifests() { .toList(); } - /// Returns a view of all instances in this status, including provisional placeholders. + /// Returns a view of all instances in this snapshot, including provisional placeholders. /// /// @return the instances; unmodifiable after [#seal()] public Collection values() { @@ -188,9 +188,9 @@ public Map asMap() { return instances; } - /// Adds or replaces an instance in this unsealed status. + /// Adds or replaces an instance in this unsealed snapshot. /// - /// @param instance the instance bound to this status + /// @param instance the instance bound to this snapshot public void put(DefaultGameInstance instance) { checkMutable(); instances.put(instance.getId(), instance); @@ -212,29 +212,29 @@ public void remove(GameInstanceID id) { instances.remove(id); } - /// Removes all instances from this unsealed status. + /// Removes all instances from this unsealed snapshot. public void clear() { checkMutable(); instances.clear(); } - /// Creates an unsealed copy of this status with instances rebound to the copy. + /// Creates an unsealed copy of this snapshot with instances rebound to the copy. /// - /// @return a mutable status ready for further edits before publish + /// @return a mutable snapshot ready for further edits before publish @Override - public DefaultGameRepositoryStatus clone() { - DefaultGameRepositoryStatus newStatus = newEmpty(); + public DefaultGameRepositorySnapshot clone() { + DefaultGameRepositorySnapshot newSnapshot = newEmpty(); for (DefaultGameInstance instance : instances.values()) { - newStatus.put(instance.withNewStatus(newStatus)); + newSnapshot.put(instance.withNewSnapshot(newSnapshot)); } - return newStatus; + return newSnapshot; } /// Resolves official-layout inheritance and patches into launch and standalone views. /// /// @param manifest the manifest to resolve /// @return the resolved manifest views - /// @throws NoSuchGameInstanceException if an inherited parent is missing from this status + /// @throws NoSuchGameInstanceException if an inherited parent is missing from this snapshot public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { return resolve(manifest, new HashSet<>()); } @@ -244,7 +244,7 @@ public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) thro /// @param manifest the manifest to resolve /// @param resolvedSoFar instance ids already visited in the inheritance chain /// @return the resolved manifest views - /// @throws NoSuchGameInstanceException if an inherited parent is missing from this status + /// @throws NoSuchGameInstanceException if an inherited parent is missing from this snapshot public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, Set resolvedSoFar) throws NoSuchGameInstanceException { GameInstanceManifest launchManifest; 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 ede33f78250..5b669f15d70 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java @@ -61,24 +61,24 @@ protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { } @Override - protected DefaultGameInstance createInstance(DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest) { + protected DefaultGameInstance createInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { final class MyGameInstance extends DefaultGameInstance { - MyGameInstance(DefaultGameRepositoryStatus status, GameInstanceID id, GameInstanceManifest manifest) { - super(status, id, manifest); + MyGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { + super(snapshot, id, manifest); } @Override - protected DefaultGameInstance withNewStatus(DefaultGameRepositoryStatus newStatus) { - return new MyGameInstance(newStatus, id, manifest); + protected DefaultGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) { + return new MyGameInstance(newSnapshot, id, manifest); } @Override - protected DefaultGameInstance withManifest(DefaultGameRepositoryStatus newStatus, GameInstanceManifest manifest) { - return new MyGameInstance(newStatus, id, manifest); + protected DefaultGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) { + return new MyGameInstance(newSnapshot, id, manifest); } } - return new MyGameInstance(status, id, manifest); + return new MyGameInstance(snapshot, id, manifest); } }.resolve(manifest); From 7bd606750692737bfd86c0d5f42b0c3a6cc46ca8 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 02:07:35 +0800 Subject: [PATCH 021/199] Add snapshot property for JavaFX bindings and update repository snapshot handling --- .../hmcl/game/DefaultGameRepository.java | 47 +++++++++++++++++++ .../jackhuang/hmcl/game/GameRepository.java | 4 +- 2 files changed, 50 insertions(+), 1 deletion(-) 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 207695d98e0..7074828096f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -18,6 +18,10 @@ package org.jackhuang.hmcl.game; import com.google.gson.JsonParseException; +import javafx.application.Platform; +import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; import org.jackhuang.hmcl.download.MaintainTask; @@ -86,13 +90,19 @@ private static boolean hasClassicVersion(Path baseDirectory) { && Files.exists(bin.resolve("lwjgl_util.jar")); } + /// Immediately-visible published snapshot for programmatic reads on any thread. private volatile DefaultGameRepositorySnapshot snapshot; + + /// Observable projection of [#snapshot], updated on the JavaFX application thread. + private final ObjectProperty snapshotProperty; + private volatile boolean loaded; public DefaultGameRepository(Path baseDirectory) { DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); initial.seal(); this.snapshot = initial; + this.snapshotProperty = new SimpleObjectProperty<>(initial); } /// Creates the repository layout rooted at the given directory. @@ -112,24 +122,61 @@ public void setBaseDirectory(Path baseDirectory) { /// The returned snapshot is sealed and must not be modified. Writers must [#clone()] it, edit the /// copy, and publish the result with [#publishSnapshot(DefaultGameRepositorySnapshot)]. /// + /// This method is safe to call from any thread and reflects the latest published value immediately, + /// including before the JavaFX [#snapshotProperty()] has been updated. + /// /// @return the current snapshot protected DefaultGameRepositorySnapshot currentSnapshot() { return snapshot; } /// {@inheritDoc} + /// + /// Safe to call from any thread. The value is updated immediately on publish; UI code that must + /// react on the JavaFX thread should observe [#snapshotProperty()] instead. @Override public GameRepositorySnapshot getSnapshot() { return snapshot; } + /// Returns a read-only view of the current published snapshot for JavaFX bindings. + /// + /// The property is updated on the JavaFX application thread when a snapshot is published from a + /// background thread, so listeners may safely touch the scene graph. The value may lag slightly + /// behind [#getSnapshot()] until the FX pulse processes the update. + /// + /// @return the observable snapshot property + public final ReadOnlyObjectProperty snapshotProperty() { + return snapshotProperty; + } + /// Seals `newSnapshot` if needed and publishes it as the current repository snapshot. /// + /// The sealed snapshot becomes visible to [#getSnapshot()] immediately. The observable + /// [#snapshotProperty()] is updated on the JavaFX application thread so UI listeners run there. + /// /// @param newSnapshot the snapshot to publish; must not already be visible as [#currentSnapshot()] /// unless it is a freshly built replacement protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { newSnapshot.seal(); this.snapshot = newSnapshot; + publishSnapshotProperty(newSnapshot); + } + + /// Updates [#snapshotProperty()] on the JavaFX application thread. + private void publishSnapshotProperty(GameRepositorySnapshot newSnapshot) { + if (Platform.isFxApplicationThread()) { + snapshotProperty.set(newSnapshot); + return; + } + + try { + // Read the volatile field inside runLater so queued publishes converge on the latest value. + Platform.runLater(() -> snapshotProperty.set(this.snapshot)); + } catch (IllegalStateException ignored) { + // JavaFX toolkit is not initialized (for example in headless unit tests). + snapshotProperty.set(newSnapshot); + } } @Override 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 08d85d08c61..d5edf424506 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -59,7 +59,9 @@ default Path getBaseDirectory() { /// Returns the current published snapshot of the registered instance index. /// /// The snapshot is immutable. Subsequent repository writes publish a replacement snapshot and - /// do not mutate the returned object. + /// do not mutate the returned object. Implementations that expose a JavaFX property for UI + /// observation may update that property asynchronously on the JavaFX thread; this method still + /// returns the latest published snapshot immediately. /// /// @return the current repository snapshot GameRepositorySnapshot getSnapshot(); From 5f8289b6aa57f0e65d6f9e12fc03cf5badd13a70 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 02:10:27 +0800 Subject: [PATCH 022/199] Refactor snapshot handling in DefaultGameRepository for clarity and thread safety --- .../hmcl/game/DefaultGameRepository.java | 76 +++++++++---------- .../jackhuang/hmcl/game/GameRepository.java | 4 +- 2 files changed, 39 insertions(+), 41 deletions(-) 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 7074828096f..d8e09fa0863 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -41,6 +41,7 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.*; +import java.util.concurrent.CountDownLatch; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -90,19 +91,15 @@ private static boolean hasClassicVersion(Path baseDirectory) { && Files.exists(bin.resolve("lwjgl_util.jar")); } - /// Immediately-visible published snapshot for programmatic reads on any thread. - private volatile DefaultGameRepositorySnapshot snapshot; - - /// Observable projection of [#snapshot], updated on the JavaFX application thread. - private final ObjectProperty snapshotProperty; + /// Published snapshot, always updated on the JavaFX application thread when the toolkit is live. + private final ObjectProperty snapshot; private volatile boolean loaded; public DefaultGameRepository(Path baseDirectory) { DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); initial.seal(); - this.snapshot = initial; - this.snapshotProperty = new SimpleObjectProperty<>(initial); + this.snapshot = new SimpleObjectProperty<>(initial); } /// Creates the repository layout rooted at the given directory. @@ -122,66 +119,69 @@ public void setBaseDirectory(Path baseDirectory) { /// The returned snapshot is sealed and must not be modified. Writers must [#clone()] it, edit the /// copy, and publish the result with [#publishSnapshot(DefaultGameRepositorySnapshot)]. /// - /// This method is safe to call from any thread and reflects the latest published value immediately, - /// including before the JavaFX [#snapshotProperty()] has been updated. - /// /// @return the current snapshot protected DefaultGameRepositorySnapshot currentSnapshot() { - return snapshot; + return (DefaultGameRepositorySnapshot) Objects.requireNonNull(snapshot.get()); } /// {@inheritDoc} - /// - /// Safe to call from any thread. The value is updated immediately on publish; UI code that must - /// react on the JavaFX thread should observe [#snapshotProperty()] instead. @Override public GameRepositorySnapshot getSnapshot() { - return snapshot; + return Objects.requireNonNull(snapshot.get()); } /// Returns a read-only view of the current published snapshot for JavaFX bindings. /// - /// The property is updated on the JavaFX application thread when a snapshot is published from a - /// background thread, so listeners may safely touch the scene graph. The value may lag slightly - /// behind [#getSnapshot()] until the FX pulse processes the update. + /// The property is the sole holder of the published snapshot. Updates are applied on the JavaFX + /// application thread so listeners may safely touch the scene graph. /// /// @return the observable snapshot property public final ReadOnlyObjectProperty snapshotProperty() { - return snapshotProperty; + return snapshot; } /// Seals `newSnapshot` if needed and publishes it as the current repository snapshot. /// - /// The sealed snapshot becomes visible to [#getSnapshot()] immediately. The observable - /// [#snapshotProperty()] is updated on the JavaFX application thread so UI listeners run there. + /// When the JavaFX toolkit is running, the property is updated on the JavaFX application thread + /// (blocking the caller if publish happens off the FX thread) so that listeners run on FX and + /// [#getSnapshot()] observes the new value before this method returns. /// /// @param newSnapshot the snapshot to publish; must not already be visible as [#currentSnapshot()] /// unless it is a freshly built replacement protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { newSnapshot.seal(); - this.snapshot = newSnapshot; - publishSnapshotProperty(newSnapshot); + setSnapshotOnFxThread(newSnapshot); } - /// Updates [#snapshotProperty()] on the JavaFX application thread. - private void publishSnapshotProperty(GameRepositorySnapshot newSnapshot) { + /// Sets [#snapshot] on the JavaFX application thread when possible. + private void setSnapshotOnFxThread(GameRepositorySnapshot newSnapshot) { if (Platform.isFxApplicationThread()) { - snapshotProperty.set(newSnapshot); + snapshot.set(newSnapshot); return; } try { - // Read the volatile field inside runLater so queued publishes converge on the latest value. - Platform.runLater(() -> snapshotProperty.set(this.snapshot)); + CountDownLatch published = new CountDownLatch(1); + Platform.runLater(() -> { + try { + snapshot.set(newSnapshot); + } finally { + published.countDown(); + } + }); + published.await(); } catch (IllegalStateException ignored) { // JavaFX toolkit is not initialized (for example in headless unit tests). - snapshotProperty.set(newSnapshot); + snapshot.set(newSnapshot); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + snapshot.set(newSnapshot); } } @Override public DefaultGameRepositoryLayout getLayout() { - return snapshot.getLayout(); + return currentSnapshot().getLayout(); } public boolean isLoaded() { @@ -200,7 +200,7 @@ public void refresh() { } protected void refreshImpl() { - DefaultGameRepositorySnapshot newSnapshot = createSnapshot(snapshot.getLayout()); + DefaultGameRepositorySnapshot newSnapshot = createSnapshot(currentSnapshot().getLayout()); if (hasClassicVersion(newSnapshot.getLayout().getBaseDirectory())) { GameInstanceID id = CLASSIC_MANIFEST.id(); @@ -338,7 +338,7 @@ private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, G @Override public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { - return snapshot.getRegistered(id); + return currentSnapshot().getRegistered(id); } /// Returns the instance recorded in the current snapshot for the given id, including provisional @@ -347,7 +347,7 @@ public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInsta /// @param id the instance id /// @return the instance, or `null` when absent from the current snapshot protected @Nullable DefaultGameInstance findSnapshotInstance(GameInstanceID id) { - return snapshot.get(id); + return currentSnapshot().get(id); } public Path getArtifactFile(GameInstanceManifest manifest, Artifact artifact) { @@ -373,7 +373,7 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { } try { - DefaultGameRepositorySnapshot newSnapshot = snapshot.clone(); + DefaultGameRepositorySnapshot newSnapshot = currentSnapshot().clone(); DefaultGameInstance fromHolder = newSnapshot.get(from); if (fromHolder == null || fromHolder.isProvisional()) { throw new NoSuchGameInstanceException(from); @@ -415,8 +415,8 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { return false; } - if (snapshot.get(id) != null) { - DefaultGameRepositorySnapshot newSnapshot = snapshot.clone(); + if (currentSnapshot().get(id) != null) { + DefaultGameRepositorySnapshot newSnapshot = currentSnapshot().clone(); newSnapshot.remove(id); publishSnapshot(newSnapshot); } @@ -591,7 +591,7 @@ public Task saveAsync(GameInstanceManifest instanceManifes Files.createDirectories(json.getParent()); JsonUtils.writeToJsonFile(json, savedManifest); - DefaultGameRepositorySnapshot newSnapshot = snapshot.clone(); + DefaultGameRepositorySnapshot newSnapshot = currentSnapshot().clone(); DefaultGameInstance existing = newSnapshot.get(savedManifest.id()); if (existing != null) { newSnapshot.put(existing.withManifest(newSnapshot, savedManifest)); @@ -629,7 +629,7 @@ public ResourcePackManager getResourcePackManager(GameInstanceID instanceId) { @Override public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { - return snapshot.resolve(manifest); + return currentSnapshot().resolve(manifest); } /// Creates an empty unsealed snapshot for the given layout. 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 d5edf424506..08d85d08c61 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -59,9 +59,7 @@ default Path getBaseDirectory() { /// Returns the current published snapshot of the registered instance index. /// /// The snapshot is immutable. Subsequent repository writes publish a replacement snapshot and - /// do not mutate the returned object. Implementations that expose a JavaFX property for UI - /// observation may update that property asynchronously on the JavaFX thread; this method still - /// returns the latest published snapshot immediately. + /// do not mutate the returned object. /// /// @return the current repository snapshot GameRepositorySnapshot getSnapshot(); From 039931aed8636a0864f5b6673d6a10bddc6654ee Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 02:19:20 +0800 Subject: [PATCH 023/199] Enhance DefaultGameInstance to support shared mod and resource-pack managers across snapshots --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 3 +- .../hmcl/game/DefaultGameInstance.java | 58 +++++++++++++++++++ .../hmcl/game/DefaultGameRepository.java | 18 ++++-- .../hmcl/game/GameInstanceManifestTest.java | 12 +++- 4 files changed, 83 insertions(+), 8 deletions(-) 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 e33f7896cf2..172b15ba3a0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -100,13 +100,12 @@ private HMCLGameInstance( GameInstanceManifest manifest, boolean provisional, HMCLGameInstance shareState) { - super(snapshot, id, manifest); + super(snapshot, id, manifest, shareState); this.provisional = provisional; this.treatingAsModpack = shareState.treatingAsModpack; this.gameSettingsLoaded = shareState.gameSettingsLoaded; this.gameSettingsReadOnly = shareState.gameSettingsReadOnly; this.gameSettings = shareState.gameSettings; - this.version = shareState.version; } @Override 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 f03d7e02b0b..ce2f2be3fda 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.game; +import org.jackhuang.hmcl.addon.mod.ModManager; +import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -27,6 +29,12 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Default snapshot member for an official-layout game instance. +/// +/// Index fields (`id`, `manifest`, layout binding) belong to a +/// [DefaultGameRepositorySnapshot]. Session services such as [#getModManager()] and +/// [#getResourcePackManager()] are lazy and are shared across [#withNewSnapshot] / +/// [#withManifest] copies so caches survive COW publishes. @NotNullByDefault public abstract class DefaultGameInstance implements GameInstance { @@ -43,6 +51,12 @@ public abstract class DefaultGameInstance implements GameInstance { /// stored as [GameVersionNumber#unknown()] rather than left null. protected @Nullable GameVersionNumber version; + /// Lazily created mod manager shared across snapshot wrappers for this instance id. + private @Nullable ModManager modManager; + + /// Lazily created resource-pack manager shared across snapshot wrappers for this instance id. + private @Nullable ResourcePackManager resourcePackManager; + protected DefaultGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, @@ -54,6 +68,24 @@ protected DefaultGameInstance( this.manifest = manifest; } + /// Creates an instance that reuses session state from another wrapper of the same logical + /// instance. + /// + /// @param snapshot the snapshot that will own the copy + /// @param id the instance id + /// @param manifest the stored instance manifest + /// @param shareSession the instance whose session services and caches should be shared + protected DefaultGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + DefaultGameInstance shareSession) { + this(snapshot, id, manifest); + this.version = shareSession.version; + this.modManager = shareSession.modManager; + this.resourcePackManager = shareSession.resourcePackManager; + } + protected abstract DefaultGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot); /// Returns a copy of this instance bound to a new snapshot and stored manifest. @@ -115,6 +147,32 @@ public GameVersionNumber getVersion() { return version; } + /// Returns the mod manager for this instance. + /// + /// The manager is created on first use and shared across snapshot wrappers produced by + /// [#withNewSnapshot] / [#withManifest]. + /// + /// @return the mod manager + public ModManager getModManager() { + if (modManager == null) { + modManager = new ModManager(repository, id); + } + return modManager; + } + + /// Returns the resource-pack manager for this instance. + /// + /// The manager is created on first use and shared across snapshot wrappers produced by + /// [#withNewSnapshot] / [#withManifest]. + /// + /// @return the resource-pack manager + public ResourcePackManager getResourcePackManager() { + if (resourcePackManager == null) { + resourcePackManager = new ResourcePackManager(repository, id); + } + return resourcePackManager; + } + /// Detects the Minecraft game version from this instance's primary client jar. /// /// @return the detected version, or [GameVersionNumber#unknown()] when detection fails 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 d8e09fa0863..b89df44681a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -619,12 +619,22 @@ public boolean isModpack(GameInstanceID instanceId) { return Files.exists(getModpackConfiguration(instanceId)); } - public ModManager getModManager(GameInstanceID instanceId) { - return new ModManager(this, instanceId); + /// Returns the mod manager for the registered instance. + /// + /// @param instanceId the instance id + /// @return the instance's shared mod manager + /// @throws NoSuchGameInstanceException if the instance is not registered + public ModManager getModManager(GameInstanceID instanceId) throws NoSuchGameInstanceException { + return getInstance(instanceId).getModManager(); } - public ResourcePackManager getResourcePackManager(GameInstanceID instanceId) { - return new ResourcePackManager(this, instanceId); + /// Returns the resource-pack manager for the registered instance. + /// + /// @param instanceId the instance id + /// @return the instance's shared resource-pack manager + /// @throws NoSuchGameInstanceException if the instance is not registered + public ResourcePackManager getResourcePackManager(GameInstanceID instanceId) throws NoSuchGameInstanceException { + return getInstance(instanceId).getResourcePackManager(); } @Override 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 5b669f15d70..11c3e91e0d7 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java @@ -67,14 +67,22 @@ final class MyGameInstance extends DefaultGameInstance { super(snapshot, id, manifest); } + 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); + return new MyGameInstance(newSnapshot, id, manifest, this); } @Override protected DefaultGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) { - return new MyGameInstance(newSnapshot, id, manifest); + return new MyGameInstance(newSnapshot, id, manifest, this); } } From a021d82e6edd45678a1fdcc3784cb675d3970e8e Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 02:35:56 +0800 Subject: [PATCH 024/199] Refactor Download and Game instance handling to use HMCLGameInstance.Optional for improved clarity and consistency --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 48 ++++++++++- .../hmcl/game/HMCLGameRepository.java | 8 -- .../jackhuang/hmcl/game/LauncherHelper.java | 17 +++- .../hmcl/ui/download/DownloadPage.java | 13 +-- .../hmcl/ui/export/ExportWizardProvider.java | 29 ++++--- .../ui/export/ModpackFileSelectionPage.java | 17 ++-- .../hmcl/ui/export/ModpackInfoPage.java | 16 ++-- .../hmcl/ui/game/GameSettingsPage.java | 12 ++- .../hmcl/ui/instances/DownloadListPage.java | 16 ++-- .../hmcl/ui/instances/DownloadPage.java | 9 +- .../ui/instances/GameInstanceIconDialog.java | 15 ++-- .../hmcl/ui/instances/GameInstancePage.java | 85 ++++++++++--------- .../jackhuang/hmcl/ui/instances/GameItem.java | 42 +++++---- .../hmcl/ui/instances/GameListItem.java | 32 ++++--- .../hmcl/ui/instances/GameListPage.java | 4 +- .../hmcl/ui/instances/InstallerListPage.java | 11 ++- .../hmcl/ui/instances/ModListPage.java | 15 ++-- .../hmcl/ui/instances/ModListPageSkin.java | 3 +- .../ui/instances/ResourcePackListPage.java | 7 +- .../hmcl/ui/instances/SchematicsPage.java | 5 +- .../hmcl/ui/instances/WorldListPage.java | 5 +- .../hmcl/ui/instances/WorldManagePage.java | 21 +++-- .../hmcl/ui/main/LauncherSettingsPage.java | 5 +- 23 files changed, 275 insertions(+), 160 deletions(-) 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 172b15ba3a0..b2e8d33aa3a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -452,7 +452,12 @@ private static void normalizeRunningDirectoryOverride(GameSettings.Instance sett private record LoadResult(@Nullable GameSettings.Instance setting, boolean allowSave) { } - /// Optional reference to an HMCL game instance and its repository. + /// Optional reference to an HMCL game instance bound to a repository. + /// + /// Replaces the former `(repository, instanceId)` pair for UI and service context that may or + /// may not have a selected instance. When present, [#instance()] is a snapshot member and may + /// become stale after the repository publishes a new snapshot; call [#refreshed()] to re-resolve + /// from the current snapshot while preserving repository context. @NotNullByDefault public static final class Optional { private final HMCLGameRepository repository; @@ -462,7 +467,7 @@ public static final class Optional { /// /// @param repository the repository public Optional(HMCLGameRepository repository) { - this.repository = repository; + this.repository = Objects.requireNonNull(repository); this.instance = null; } @@ -474,6 +479,35 @@ public Optional(HMCLGameInstance instance) { this.instance = instance; } + /// Creates an optional by resolving `instanceId` from the repository's current snapshot. + /// + /// @param repository the repository + /// @param instanceId the instance id, or `null` for an empty optional + /// @return an optional that is empty when `instanceId` is null or not registered + public static Optional of(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { + if (instanceId == null) { + return new Optional(repository); + } + HMCLGameInstance instance = repository.findInstance(instanceId); + return instance != null ? new Optional(instance) : new Optional(repository); + } + + /// Creates an optional that holds the given instance. + /// + /// @param instance the instance + /// @return the optional + public static Optional of(HMCLGameInstance instance) { + return new Optional(instance); + } + + /// Creates an empty optional bound only to a repository. + /// + /// @param repository the repository + /// @return the empty optional + public static Optional empty(HMCLGameRepository repository) { + return new Optional(repository); + } + /// Returns the repository associated with this optional. /// /// @return the repository @@ -510,5 +544,15 @@ public boolean isPresent() { public boolean isEmpty() { return instance == null; } + + /// Re-resolves the held instance id from the repository's current snapshot. + /// + /// @return this optional when empty; otherwise a fresh optional for the same id + public Optional refreshed() { + if (instance == null) { + return this; + } + return of(repository, instance.getId()); + } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java index 0e6375f3872..00becbe2f34 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -69,14 +69,6 @@ /// HMCL game repository implementation backed by a GameDirectory and per-instance game settings. @NotNullByDefault public final class HMCLGameRepository extends DefaultGameRepository { - /// References an optional game instance in a repository. - /// - /// @param repository the owning game repository - /// @param instanceId the game instance ID, or `null` when only repository context is available - @NotNullByDefault - public record InstanceReference(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - } - /// The persistent game directory for this repository. private final GameDirectory gameDirectory; 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 2d7f4a0c87b..4b3dbeebc80 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -84,6 +84,7 @@ public final class LauncherHelper { private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm"; + private final HMCLGameInstance gameInstance; private final HMCLGameRepository repository; private Account account; private final GameInstanceID selectedInstanceId; @@ -94,16 +95,26 @@ public final class LauncherHelper { private QuickPlayOption quickPlayOption; private boolean disableOfflineSkin = false; - public LauncherHelper(HMCLGameRepository repository, Account account, GameInstanceID selectedInstanceId) { - this.repository = Objects.requireNonNull(repository); + public LauncherHelper(HMCLGameInstance gameInstance, Account account) { + this.gameInstance = Objects.requireNonNull(gameInstance); + this.repository = gameInstance.getRepository(); this.account = Objects.requireNonNull(account); - this.selectedInstanceId = selectedInstanceId; + this.selectedInstanceId = gameInstance.getId(); this.setting = repository.getEffectiveGameSettings(selectedInstanceId); this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty); this.showLogs = setting.getInheritable(GameSettings::showLogsProperty); this.launchingStepsPane.setTitle(i18n("instance.launch")); } + public LauncherHelper(HMCLGameRepository repository, Account account, GameInstanceID selectedInstanceId) { + this(Objects.requireNonNull(repository.findInstance(selectedInstanceId), + () -> "Instance not found: " + selectedInstanceId), account); + } + + public HMCLGameInstance getGameInstance() { + return gameInstance; + } + private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL); public Account getAccount() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index e961c901bfd..13499c15afb 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 @@ -26,6 +26,7 @@ import org.jackhuang.hmcl.download.*; import org.jackhuang.hmcl.download.game.GameRemoteVersion; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameDirectoryManager; @@ -136,7 +137,7 @@ private static Supplier loadVersionFor(Supplier nodeSuppl return () -> { T node = nodeSupplier.get(); if (node instanceof GameInstancePage.GameInstanceLoadable loadable) { - loadable.loadInstance(GameDirectoryManager.getSelectedRepository(), null); + loadable.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())); } return node; }; @@ -191,19 +192,19 @@ private void loadVersions(HMCLGameRepository repository) { if (repository.getGameDirectory() == GameDirectoryManager.getSelectedGameDirectory()) { listenerHolder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), version -> { if (modTab.isInitialized()) { - modTab.getNode().loadInstance(repository, null); + modTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); } if (modpackTab.isInitialized()) { - modpackTab.getNode().loadInstance(repository, null); + modpackTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); } if (resourcePackTab.isInitialized()) { - resourcePackTab.getNode().loadInstance(repository, null); + resourcePackTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); } if (shaderTab.isInitialized()) { - shaderTab.getNode().loadInstance(repository, null); + shaderTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); } if (worldTab.isInitialized()) { - worldTab.getNode().loadInstance(repository, null); + worldTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); } })); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java index 6ffb6676068..919ca1616c9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java @@ -20,7 +20,9 @@ import javafx.scene.Node; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; +import java.util.Objects; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.ModpackExportInfo; import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask; @@ -47,12 +49,15 @@ import static org.jackhuang.hmcl.setting.SettingsManager.settings; public final class ExportWizardProvider implements WizardProvider { - private final HMCLGameRepository repository; - private final GameInstanceID instanceId; + private final HMCLGameInstance gameInstance; + + public ExportWizardProvider(HMCLGameInstance gameInstance) { + this.gameInstance = gameInstance; + } public ExportWizardProvider(HMCLGameRepository repository, GameInstanceID instanceId) { - this.repository = repository; - this.instanceId = instanceId; + this(Objects.requireNonNull(repository.findInstance(instanceId), + () -> "Instance not found: " + instanceId)); } @Override @@ -165,7 +170,7 @@ private Task exportAsMcbbs(ModpackExportInfo exportInfo, Path modpackFile) { @Override public void execute() { - dependency = new McbbsModpackExportTask(repository, instanceId, exportInfo, modpackFile); + dependency = new McbbsModpackExportTask(gameInstance.getRepository(), gameInstance.getId(), exportInfo, modpackFile); } @Override @@ -185,8 +190,8 @@ private Task exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) @Override public void execute() { - GameSettings.Effective setting = repository.getEffectiveGameSettings(instanceId); - dependency = new MultiMCModpackExportTask(repository, instanceId, exportInfo.getWhitelist(), + GameSettings.Effective setting = gameInstance.getRepository().getEffectiveGameSettings(gameInstance.getId()); + dependency = new MultiMCModpackExportTask(gameInstance.getRepository(), gameInstance.getId(), exportInfo.getWhitelist(), new MultiMCInstanceConfiguration( "OneSix", exportInfo.getName() + "-" + exportInfo.getVersion(), @@ -233,7 +238,7 @@ private Task exportAsServer(ModpackExportInfo exportInfo, Path modpackFile) { @Override public void execute() { - dependency = new ServerModpackExportTask(repository, instanceId, exportInfo, modpackFile); + dependency = new ServerModpackExportTask(gameInstance.getRepository(), gameInstance.getId(), exportInfo, modpackFile); } @Override @@ -254,8 +259,8 @@ private Task exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) @Override public void execute() { dependency = new ModrinthModpackExportTask( - repository, - instanceId, + gameInstance.getRepository(), + gameInstance.getId(), exportInfo, modpackFile ); @@ -272,8 +277,8 @@ public Collection> getDependencies() { public Node createPage(WizardController controller, int step, SettingsMap settings) { return switch (step) { case 0 -> new ModpackTypeSelectionPage(controller); - case 1 -> new ModpackInfoPage(controller, repository, instanceId); - case 2 -> new ModpackFileSelectionPage(controller, repository, instanceId, ModAdviser::suggestMod); + case 1 -> new ModpackInfoPage(controller, gameInstance); + case 2 -> new ModpackFileSelectionPage(controller, gameInstance, ModAdviser::suggestMod); default -> throw new IllegalArgumentException("step"); }; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java index b273bb308af..61007ae2642 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java @@ -29,6 +29,7 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.task.Schedulers; @@ -62,14 +63,16 @@ */ public final class ModpackFileSelectionPage extends BorderPane implements WizardPage { private final WizardController controller; - private final GameInstanceID instanceId; + private final HMCLGameInstance gameInstance; private final ModAdviser adviser; private @Nullable ModpackFileTreeItem rootNode; - public ModpackFileSelectionPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId, ModAdviser adviser) { + public ModpackFileSelectionPage(WizardController controller, HMCLGameInstance gameInstance, ModAdviser adviser) { this.controller = controller; - this.instanceId = instanceId; + this.gameInstance = gameInstance; this.adviser = adviser; + HMCLGameRepository repository = gameInstance.getRepository(); + GameInstanceID instanceId = gameInstance.getId(); JFXTreeView treeView = new JFXTreeView<>(); treeView.setSelectionModel(new NoneMultipleSelectionModel<>()); @@ -107,7 +110,7 @@ private void loadRoot(HMCLGameRepository repository, JFXTreeView treeVie spinnerPane.setLoading(true); btnNext.setDisable(true); CompletableFuture - .supplyAsync(() -> getTreeItem(repository.getRunDirectory(instanceId), "minecraft", 0), Schedulers.io()) + .supplyAsync(() -> getTreeItem(repository.getRunDirectory(gameInstance.getId()), "minecraft", 0), Schedulers.io()) .whenCompleteAsync((root, throwable) -> { if (throwable == null) { if (root != null) { @@ -145,12 +148,12 @@ private ModpackFileTreeItem getTreeItem(Path file, String basePath, int level) { } if (fileName.startsWith("._")) // macOS system file state = ModAdviser.ModSuggestion.HIDDEN; - if (FileUtils.getNameWithoutExtension(file).equals(instanceId.toString())) + if (FileUtils.getNameWithoutExtension(file).equals(gameInstance.getId().toString())) state = ModAdviser.ModSuggestion.HIDDEN; } if (isDirectory) { - if (fileName.equals(instanceId + "-natives")) { // Ignore -natives + if (fileName.equals(gameInstance.getId() + "-natives")) { // Ignore -natives state = ModAdviser.ModSuggestion.HIDDEN; } if (level == 1 && fileName.startsWith("natives-")) { // Ignore natives-os-arch @@ -161,7 +164,7 @@ private ModpackFileTreeItem getTreeItem(Path file, String basePath, int level) { return null; } - ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? instanceId.toString() : StringUtils.substringAfterLast(basePath, '/'), basePath); + ModpackFileTreeItem node = new ModpackFileTreeItem(level == 0 ? gameInstance.getId().toString() : StringUtils.substringAfterLast(basePath, '/'), basePath); if (state == ModAdviser.ModSuggestion.SUGGESTED) node.setSelected(true); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java index 6634ca4ca86..f815d6f0bc8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java @@ -36,7 +36,9 @@ import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; +import java.util.Objects; import org.jackhuang.hmcl.modpack.ModpackExportInfo; import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest; import org.jackhuang.hmcl.setting.Accounts; @@ -65,9 +67,8 @@ public final class ModpackInfoPage extends Control implements WizardPage { private final WizardController controller; - private final HMCLGameRepository repository; + private final HMCLGameInstance gameInstance; private final ModpackExportInfo.Options options; - private final GameInstanceID instanceId; private final boolean canIncludeLauncher; private final ModpackExportInfo exportInfo = new ModpackExportInfo(); @@ -88,19 +89,18 @@ public final class ModpackInfoPage extends Control implements WizardPage { private final SimpleBooleanProperty noCreateRemoteFiles = new SimpleBooleanProperty(); private final SimpleBooleanProperty skipCurseForgeRemoteFiles = new SimpleBooleanProperty(); - public ModpackInfoPage(WizardController controller, HMCLGameRepository repository, GameInstanceID instanceId) { + public ModpackInfoPage(WizardController controller, HMCLGameInstance gameInstance) { this.controller = controller; - this.repository = repository; + this.gameInstance = gameInstance; this.options = controller.getSettings().get(MODPACK_INFO_OPTION); - this.instanceId = instanceId; if (this.options == null) throw new IllegalArgumentException("Settings.MODPACK_INFO_OPTION is required"); - name.set(instanceId.toString()); + name.set(gameInstance.getId().toString()); author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getProfileName).orElse("")); - GameSettings.Effective versionSetting = repository.getEffectiveGameSettings(this.instanceId); + GameSettings.Effective versionSetting = gameInstance.getRepository().getEffectiveGameSettings(gameInstance.getId()); minMemory.set(Optional.ofNullable(versionSetting.getInheritable(GameSettings::minMemoryProperty)).orElse(0)); launchArguments.set(versionSetting.getInheritable(GameSettings::gameArgumentsProperty)); javaArguments.set(versionSetting.getInheritable(GameSettings::jvmOptionsProperty)); @@ -213,7 +213,7 @@ public ModpackInfoPageSkin(ModpackInfoPage skinnable) { var instanceNamePane = new LineTextPane(); { instanceNamePane.setTitle(i18n("modpack.wizard.step.initialization.exported_version")); - instanceNamePane.setText(skinnable.instanceId.toString()); + instanceNamePane.setText(skinnable.gameInstance.getId().toString()); list.getContent().add(instanceNamePane); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java index b916dfb0f69..7d21c053d8d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.ui.game; +import org.jackhuang.hmcl.game.HMCLGameInstance; + import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXComboBox; import com.jfoenix.controls.JFXSlider; @@ -2630,7 +2632,9 @@ public ReadOnlyObjectProperty stateProperty() { @SuppressWarnings("unchecked") @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { + public void loadInstance(HMCLGameInstance.Optional instance) { + HMCLGameRepository repository = instance.repository(); + @Nullable GameInstanceID instanceId = instance.instanceId(); this.gameDirectory = repository.getGameDirectory(); this.repository = repository; this.instanceId = instanceId; @@ -2840,7 +2844,11 @@ private void onExploreIcon() { if (repository == null || instanceId == null) return; - Controllers.dialog(new GameInstanceIconDialog(repository, instanceId, this::loadIcon)); + HMCLGameInstance gameInstance = repository.findInstance(instanceId); + if (gameInstance == null) { + return; + } + Controllers.dialog(new GameInstanceIconDialog(gameInstance, this::loadIcon)); } private void onDeleteIcon() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java index f39535a4c38..226aa11ff25 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java @@ -40,6 +40,7 @@ import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.RemoteAddonRepository; @@ -73,7 +74,7 @@ public class DownloadListPage extends Control implements DecoratorPage, GameInst private final BooleanProperty loading = new SimpleBooleanProperty(false); private final BooleanProperty failed = new SimpleBooleanProperty(false); private final boolean instanceSelection; - private final ObjectProperty instanceReference = new SimpleObjectProperty<>(); + private final ObjectProperty instanceReference = new SimpleObjectProperty<>(); private final IntegerProperty pageOffset = new SimpleIntegerProperty(0); private final IntegerProperty pageCount = new SimpleIntegerProperty(-1); private final ListProperty items = new SimpleListProperty<>(this, "items", FXCollections.observableArrayList()); @@ -112,8 +113,8 @@ public ObservableList getActions() { } @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId)); + public void loadInstance(HMCLGameInstance.Optional instance) { + this.instanceReference.set(instance); setLoading(false); setFailed(false); @@ -124,6 +125,7 @@ public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID } if (instanceSelection) { + HMCLGameRepository repository = instance.repository(); instances.setAll(repository.getDisplayInstanceManifests() .map(GameInstanceManifest::id) .toList()); @@ -166,7 +168,7 @@ private void search(String userGameVersion, RemoteAddonRepository.Category categ int currentSearchID = searchID = searchID + 1; Task.supplyAsync(() -> { - HMCLGameRepository.InstanceReference instanceReference = this.instanceReference.get(); + HMCLGameInstance.Optional instanceReference = this.instanceReference.get(); if (instanceReference.instanceId() == null) { return userGameVersion; } else { @@ -217,10 +219,10 @@ protected String getLocalizedOfficialPage() { } } - protected HMCLGameRepository.InstanceReference getInstanceReference() { + protected HMCLGameInstance.Optional getInstanceOptional() { if (instanceSelection) { @Nullable GameInstanceID instanceId = selectedInstance.get(); - return new HMCLGameRepository.InstanceReference(instanceReference.get().repository(), instanceId); + return HMCLGameInstance.Optional.of(instanceReference.get().repository(), instanceId); } else { return instanceReference.get(); } @@ -570,7 +572,7 @@ protected ModDownloadListPageSkin(DownloadListPage control) { FXUtils.onClicked(wrapper, () -> { RemoteAddon item = getItem(); if (item != null) - Controllers.navigate(new DownloadPage(getSkinnable(), item, getSkinnable().getInstanceReference(), getSkinnable().callback)); + Controllers.navigate(new DownloadPage(getSkinnable(), item, getSkinnable().getInstanceOptional(), getSkinnable().callback)); }); setPrefWidth(0); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java index afddbe07639..e9469f15dab 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java @@ -33,6 +33,7 @@ import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.addon.RemoteAddon; @@ -67,14 +68,14 @@ public class DownloadPage extends Control implements DecoratorPage { private final ModTranslations translations; private final RemoteAddon addon; private final ModTranslations.Mod mod; - private final HMCLGameRepository.InstanceReference instanceReference; + private final HMCLGameInstance.Optional instanceReference; private final DownloadCallback callback; private final DownloadListPage page; private final RemoteAddon.Type type; private SimpleMultimap> versions; - public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameRepository.InstanceReference instanceReference, @Nullable DownloadCallback callback) { + public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference, @Nullable DownloadCallback callback) { this.page = page; this.repository = page.repository; this.addon = addon; @@ -128,7 +129,7 @@ public RemoteAddon getAddon() { return addon; } - public HMCLGameRepository.InstanceReference getInstanceReference() { + public HMCLGameInstance.Optional getInstanceOptional() { return instanceReference; } @@ -373,7 +374,7 @@ private static final class DependencyAddonItem extends LineButton { public final RemoteAddon addon; - DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameRepository.InstanceReference instanceReference) { + DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference) { this.addon = addon; HBox pane = new HBox(8); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java index a81828116f4..5e787794134 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java @@ -23,6 +23,7 @@ import javafx.stage.FileChooser; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.GameInstanceIconType; @@ -39,16 +40,14 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class GameInstanceIconDialog extends DialogPane { - private final HMCLGameRepository repository; - private final GameInstanceID instanceId; + private final HMCLGameInstance gameInstance; private final Runnable onFinish; private final GameSettings.Instance setting; - public GameInstanceIconDialog(HMCLGameRepository repository, GameInstanceID instanceId, Runnable onFinish) { - this.repository = repository; - this.instanceId = instanceId; + public GameInstanceIconDialog(HMCLGameInstance gameInstance, Runnable onFinish) { + this.gameInstance = gameInstance; this.onFinish = onFinish; - this.setting = repository.getInstanceGameSettingsOrCreate(this.instanceId); + this.setting = gameInstance.getRepository().getInstanceGameSettingsOrCreate(gameInstance.getId()); setTitle(i18n("settings.icon")); FlowPane pane = new FlowPane(); @@ -79,7 +78,7 @@ private void exploreIcon() { Path selectedFile = Controllers.showOpenDialog(chooser); if (selectedFile != null) { try { - repository.setInstanceIconFile(instanceId, selectedFile); + gameInstance.getRepository().setInstanceIconFile(gameInstance.getId(), selectedFile); if (setting != null) { setting.iconProperty().setValue(GameInstanceIconType.DEFAULT); @@ -119,7 +118,7 @@ private Node createIcon(GameInstanceIconType type) { @Override protected void onAccept() { - repository.onInstanceIconChanged.fireEvent(new Event(this)); + gameInstance.getRepository().onInstanceIconChanged.fireEvent(new Event(this)); onFinish.run(); super.onAccept(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index e438bae061b..89b56c9c1c7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -30,6 +30,7 @@ import org.jackhuang.hmcl.event.EventPriority; import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.task.Schedulers; @@ -65,7 +66,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty(); - private final ObjectProperty instanceReference = new SimpleObjectProperty<>(); + private final ObjectProperty instance = new SimpleObjectProperty<>(); private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private GameInstanceID preferredInstanceId = null; @@ -92,17 +93,20 @@ public GameInstancePage() { addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); addEventHandler(WorkingDirChangedEvent.EVENT_TYPE, event -> { - if (this.instanceReference.get() != null) { + HMCLGameInstance.Optional current = this.instance.get(); + if (current != null) { + current = current.refreshed(); + this.instance.set(current); if (installerListTab.isInitialized()) - installerListTab.getNode().loadInstance(getRepository(), getInstanceId()); + installerListTab.getNode().loadInstance(current); if (modListTab.isInitialized()) - modListTab.getNode().loadInstance(getRepository(), getInstanceId()); + modListTab.getNode().loadInstance(current); if (resourcePackTab.isInitialized()) - resourcePackTab.getNode().loadInstance(getRepository(), getInstanceId()); + resourcePackTab.getNode().loadInstance(current); if (worldListTab.isInitialized()) - worldListTab.getNode().loadInstance(getRepository(), getInstanceId()); + worldListTab.getNode().loadInstance(current); if (schematicsTab.isInitialized()) - schematicsTab.getNode().loadInstance(getRepository(), getInstanceId()); + schematicsTab.getNode().loadInstance(current); } }); @@ -111,12 +115,13 @@ public GameInstancePage() { private void checkSelectedInstance() { runInFX(() -> { - if (this.instanceReference.get() == null) return; - HMCLGameRepository repository = this.instanceReference.get().repository(); - @Nullable GameInstanceID instanceId = this.instanceReference.get().instanceId(); - if (instanceId == null || !repository.hasInstance(instanceId)) { + HMCLGameInstance.Optional current = this.instance.get(); + if (current == null) return; + current = current.refreshed(); + this.instance.set(current); + if (current.isEmpty()) { if (preferredInstanceId != null) { - loadInstance(preferredInstanceId, repository); + loadInstance(preferredInstanceId, current.repository()); } else { fireEvent(new PageCloseEvent()); } @@ -127,11 +132,9 @@ private void checkSelectedInstance() { private Supplier loadInstanceFor(Supplier nodeSupplier) { return () -> { T node = nodeSupplier.get(); - if (instanceReference.get() != null) { - if (node instanceof GameInstancePage.GameInstanceLoadable loadable) { - @Nullable GameInstanceID instanceId = instanceReference.get().instanceId(); - loadable.loadInstance(instanceReference.get().repository(), instanceId); - } + HMCLGameInstance.Optional current = instance.get(); + if (current != null && node instanceof GameInstancePage.GameInstanceLoadable loadable) { + loadable.loadInstance(current); } return node; }; @@ -142,38 +145,39 @@ public void showInstanceSettings() { } public void setInstance(GameInstanceID instanceId, HMCLGameRepository repository) { - this.instanceReference.set(new HMCLGameRepository.InstanceReference(repository, instanceId)); + this.instance.set(HMCLGameInstance.Optional.of(repository, instanceId)); } public void loadInstance(GameInstanceID instanceId, HMCLGameRepository repository) { // If we jumped to game list page and deleted this version // and back to this page, we should return to main page. - if (this.instanceReference.get() != null && (!getRepository().isLoaded() || + if (this.instance.get() != null && (!getRepository().isLoaded() || !getRepository().hasInstance(instanceId))) { Platform.runLater(() -> fireEvent(new PageCloseEvent())); return; } - setInstance(instanceId, repository); + HMCLGameInstance.Optional current = HMCLGameInstance.Optional.of(repository, instanceId); + this.instance.set(current); preferredInstanceId = instanceId; if (gameSettingsTab.isInitialized()) - gameSettingsTab.getNode().loadInstance(repository, instanceId); + gameSettingsTab.getNode().loadInstance(current); if (installerListTab.isInitialized()) - installerListTab.getNode().loadInstance(repository, instanceId); + installerListTab.getNode().loadInstance(current); if (modListTab.isInitialized()) - modListTab.getNode().loadInstance(repository, instanceId); + modListTab.getNode().loadInstance(current); if (resourcePackTab.isInitialized()) - resourcePackTab.getNode().loadInstance(repository, instanceId); + resourcePackTab.getNode().loadInstance(current); if (worldListTab.isInitialized()) - worldListTab.getNode().loadInstance(repository, instanceId); + worldListTab.getNode().loadInstance(current); if (schematicsTab.isInitialized()) - schematicsTab.getNode().loadInstance(repository, instanceId); + schematicsTab.getNode().loadInstance(current); currentInstanceUpgradable.set(repository.isModpack(instanceId)); } private void onNavigated(Navigator.NavigationEvent event) { - if (this.instanceReference.get() == null) + if (this.instance.get() == null) throw new IllegalStateException(); // If we jumped to game list page and deleted this version @@ -209,9 +213,9 @@ private void clearLibraries() { private void clearAssets() { Path assetsDir = getRepository().getBaseDirectory().resolve("assets"); - HMCLGameRepository.InstanceReference currentInstanceReference = instanceReference.get(); - Path resourcesDir = currentInstanceReference != null - ? getRepository().getRunDirectory(currentInstanceReference.instanceId()).resolve("resources") + HMCLGameInstance.Optional current = instance.get(); + Path resourcesDir = current != null && current.isPresent() + ? current.instance().getRunDirectory().resolve("resources") : null; Task.runAsync(Schedulers.io(), () -> { @@ -260,13 +264,17 @@ private void duplicate() { } public HMCLGameRepository getRepository() { - return Optional.ofNullable(instanceReference.get()).map(HMCLGameRepository.InstanceReference::repository).orElse(null); + HMCLGameInstance.Optional current = instance.get(); + return current != null ? current.repository() : null; } public @Nullable GameInstanceID getInstanceId() { - return Optional.ofNullable(instanceReference.get()) - .map(HMCLGameRepository.InstanceReference::instanceId) - .orElse(null); + HMCLGameInstance.Optional current = instance.get(); + return current != null ? current.instanceId() : null; + } + + public HMCLGameInstance.Optional getInstance() { + return instance.get(); } @Override @@ -350,7 +358,7 @@ protected Skin(GameInstancePage control) { control.state.bind(Bindings.createObjectBinding(() -> State.fromTitle(i18n("instance.manage.manage.title", getSkinnable().getInstanceId()), -1), - getSkinnable().instanceReference)); + getSkinnable().instance)); //control.transitionPane.getStyleClass().add("gray-background"); //FXUtils.setOverflowHidden(control.transitionPane, 8); @@ -360,10 +368,9 @@ protected Skin(GameInstancePage control) { /// Loads page content for a game instance in a repository. public interface GameInstanceLoadable { - /// Loads page content for the given repository and game instance. + /// Loads page content for the given optional game instance. /// - /// @param repository the repository containing the game instance - /// @param instanceId the game instance ID, or `null` when only repository context is available - void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId); + /// @param instance the instance context; may be empty when only repository context is available + void loadInstance(HMCLGameInstance.Optional instance); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java index 99594fa77f7..891de9fe3a8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java @@ -21,6 +21,7 @@ import javafx.scene.image.Image; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.setting.GameDirectory; @@ -43,9 +44,7 @@ public class GameItem { private static final ThreadPoolExecutor POOL_VERSION_RESOLVE = threadPool("VersionResolve", true, 1, 10, TimeUnit.SECONDS); - protected final HMCLGameRepository repository; - protected final String id; - protected final GameInstanceID instanceId; + protected final HMCLGameInstance gameInstance; private boolean initialized = false; private StringProperty title; @@ -53,22 +52,33 @@ public class GameItem { private StringProperty subtitle; private ObjectProperty image; + public GameItem(HMCLGameInstance gameInstance) { + this.gameInstance = gameInstance; + } + public GameItem(HMCLGameRepository repository, GameInstanceID instanceId) { - this.repository = repository; - this.id = instanceId.toString(); - this.instanceId = instanceId; + this(Objects.requireNonNull(repository.findInstance(instanceId), + () -> "Instance not found: " + instanceId)); } public GameDirectory getGameDirectory() { - return repository.getGameDirectory(); + return gameInstance.getRepository().getGameDirectory(); } public HMCLGameRepository getRepository() { - return repository; + return gameInstance.getRepository(); + } + + public GameInstanceID getInstanceId() { + return gameInstance.getId(); + } + + public HMCLGameInstance getGameInstance() { + return gameInstance; } public String getId() { - return id; + return gameInstance.getId().toString(); } private void init() { @@ -86,13 +96,13 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { CompletableFuture.supplyAsync(() -> { // GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms) - Optional gameVersion = repository.getGameVersion(instanceId); + Optional gameVersion = gameInstance.getRepository().getGameVersion(gameInstance.getId()); String modPackVersion = null; try { - ModpackConfiguration config = repository.readModpackConfiguration(instanceId); + ModpackConfiguration config = gameInstance.getRepository().readModpackConfiguration(gameInstance.getId()); modPackVersion = config != null ? config.getVersion() : null; } catch (IOException e) { - LOG.warning("Failed to read modpack configuration from " + id, e); + LOG.warning("Failed to read modpack configuration from " + getId(), e); } return new Result(gameVersion.orElse(null), modPackVersion); }, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> { @@ -102,7 +112,7 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { } StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown"))); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), result.gameVersion); + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(gameInstance.getResolvedManifest(), result.gameVersion); for (LibraryAnalyzer.LibraryMark mark : analyzer) { String libraryId = mark.getLibraryId(); String libraryVersion = mark.getLibraryVersion(); @@ -116,12 +126,12 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { subtitle.set(libraries.toString()); } else { - LOG.warning("Failed to read version info from " + id, exception); + LOG.warning("Failed to read version info from " + getId(), exception); } }, Schedulers.javafx()); - title.set(id); - image.set(repository.getInstanceIconImage(instanceId)); + title.set(getId()); + image.set(gameInstance.getRepository().getInstanceIconImage(gameInstance.getId())); } public ReadOnlyStringProperty titleProperty() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java index 16ae0a4cc62..4366b4c9772 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java @@ -22,6 +22,7 @@ import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.GameDirectoryManager; @@ -31,8 +32,10 @@ public class GameListItem extends GameItem { private final boolean isModpack; private final BooleanProperty selected = new SimpleBooleanProperty(this, "selected"); - public GameListItem(HMCLGameRepository repository, GameInstanceID instanceId) { - super(repository, instanceId); + public GameListItem(HMCLGameInstance gameInstance) { + super(gameInstance); + HMCLGameRepository repository = gameInstance.getRepository(); + GameInstanceID instanceId = gameInstance.getId(); this.isModpack = repository.isModpack(instanceId); selected.bind(Bindings.createBooleanBinding( () -> { @@ -43,44 +46,49 @@ public GameListItem(HMCLGameRepository repository, GameInstanceID instanceId) { GameDirectoryManager.selectedInstanceProperty())); } + public GameListItem(HMCLGameRepository repository, GameInstanceID instanceId) { + this(Objects.requireNonNull(repository.findInstance(instanceId), + () -> "Instance not found: " + instanceId)); + } + public ReadOnlyBooleanProperty selectedProperty() { return selected; } public void rename() { - Instances.renameInstance(repository, instanceId); + Instances.renameInstance(getRepository(), getInstanceId()); } public void duplicate() { - Instances.duplicateInstance(repository, instanceId); + Instances.duplicateInstance(getRepository(), getInstanceId()); } public void remove() { - Instances.deleteInstance(repository, instanceId); + Instances.deleteInstance(getRepository(), getInstanceId()); } public void export() { - Instances.exportInstance(repository, instanceId); + Instances.exportInstance(getRepository(), getInstanceId()); } public void browse() { - Instances.openFolder(repository, instanceId); + Instances.openFolder(getRepository(), getInstanceId()); } public void testGame() { - Instances.testGame(repository, instanceId); + Instances.testGame(getRepository(), getInstanceId()); } public void launch() { - Instances.launch(repository, instanceId); + Instances.launch(getRepository(), getInstanceId()); } public void modifyGameSettings() { - Instances.modifyGameSettings(repository, instanceId); + Instances.modifyGameSettings(getRepository(), getInstanceId()); } public void generateLaunchScript() { - Instances.generateLaunchScript(repository, instanceId); + Instances.generateLaunchScript(getRepository(), getInstanceId()); } public boolean canUpdate() { @@ -88,6 +96,6 @@ public boolean canUpdate() { } public void update() { - Instances.updateInstance(repository, instanceId); + Instances.updateInstance(getRepository(), getInstanceId()); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java index 55a6118b394..ae369f609c3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java @@ -176,12 +176,12 @@ private Predicate createPredicate(String searchText) { String regex = searchText.substring("regex:".length()); try { Pattern pattern = Pattern.compile(regex, Pattern.CASE_INSENSITIVE); - return item -> pattern.matcher(item.id).find(); + return item -> pattern.matcher(item.getId()).find(); } catch (PatternSyntaxException e) { return item -> false; } } else { - return item -> item.id.toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT)); + return item -> item.getId().toLowerCase(Locale.ROOT).contains(searchText.toLowerCase(Locale.ROOT)); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java index d97a864bf25..83821c3f480 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 @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -63,7 +64,9 @@ protected Skin createDefaultSkin() { } @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { + public void loadInstance(HMCLGameInstance.Optional instance) { + HMCLGameRepository repository = instance.repository(); + @Nullable GameInstanceID instanceId = instance.instanceId(); this.repository = repository; this.instanceId = instanceId; this.manifest = repository.getInstanceManifest(instanceId); @@ -106,7 +109,7 @@ public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId)) + .withRunAsync(Schedulers.javafx(), () -> loadInstance(HMCLGameInstance.Optional.of(this.repository, this.instanceId))) .start()); itemsProperty().add(item); @@ -128,7 +131,7 @@ public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> loadInstance(this.repository, this.instanceId)) + .withRunAsync(Schedulers.javafx(), () -> loadInstance(HMCLGameInstance.Optional.of(this.repository, this.instanceId))) .start()); itemsProperty().add(installerItem); @@ -153,7 +156,7 @@ private void doInstallOffline(Path file) { public void onStop(boolean success, TaskExecutor executor) { runInFX(() -> { if (success) { - loadInstance(repository, instanceId); + loadInstance(HMCLGameInstance.Optional.of(repository, instanceId)); Controllers.dialog(i18n("install.success")); } else { if (executor.getException() == null) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index cc11d1d7125..a77d39b2b4d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -23,6 +23,7 @@ import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; @@ -84,14 +85,18 @@ public void refresh() { } @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { - this.repository = repository; - this.instanceId = instanceId; + public void loadInstance(HMCLGameInstance.Optional instance) { + this.repository = instance.repository(); + this.instanceId = instance.instanceId(); + HMCLGameInstance gameInstance = instance.instance(); + if (gameInstance == null) { + return; + } - GameInstanceManifest resolved = repository.getResolvedInstanceManifest(instanceId).standaloneManifest(); + GameInstanceManifest resolved = gameInstance.getResolvedManifest().standaloneManifest(); this.gameVersion = repository.getGameVersion(resolved).orElse(null); - loadMods(repository.getModManager(instanceId)); + loadMods(gameInstance.getModManager()); } private void loadMods(ModManager modManager) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java index 154705397d1..3747ec5361e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java @@ -44,6 +44,7 @@ import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameInstanceIconType; @@ -486,7 +487,7 @@ final class ModInfoDialog extends JFXDialogLayout { Controllers.navigate(new DownloadPage( repository instanceof CurseForgeRemoteAddonRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false), remoteAddon, - new HMCLGameRepository.InstanceReference(ModListPageSkin.this.getSkinnable().getRepository(), ModListPageSkin.this.getSkinnable().getInstanceId()), + HMCLGameInstance.Optional.of(ModListPageSkin.this.getSkinnable().getRepository(), ModListPageSkin.this.getSkinnable().getInstanceId()), org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD )); }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java index f98fa0fc46e..719b5a5ccc4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java @@ -44,6 +44,7 @@ import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile; import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.SettingsManager; @@ -108,7 +109,9 @@ protected Skin createDefaultSkin() { } @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { + public void loadInstance(HMCLGameInstance.Optional instance) { + HMCLGameRepository repository = instance.repository(); + @Nullable GameInstanceID instanceId = instance.instanceId(); this.repository = repository; this.instanceId = instanceId; this.resourcePackManager = new ResourcePackManager(repository, instanceId); @@ -645,7 +648,7 @@ private static final class ResourcePackInfoDialog extends JFXDialogLayout { ? HMCLLocalizedDownloadListPage.ofCurseForgeResourcePack(null, false) : HMCLLocalizedDownloadListPage.ofModrinthResourcePack(null, false), remoteAddon, - new HMCLGameRepository.InstanceReference(page.repository, page.instanceId), + HMCLGameInstance.Optional.of(page.repository, page.instanceId), org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK )); }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java index b7e21447c37..9dc3a4a9d25 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java @@ -35,6 +35,7 @@ import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.schematic.LitematicFile; import org.jackhuang.hmcl.task.Schedulers; @@ -88,7 +89,9 @@ protected Skin createDefaultSkin() { } @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { + public void loadInstance(HMCLGameInstance.Optional instance) { + HMCLGameRepository repository = instance.repository(); + @Nullable GameInstanceID instanceId = instance.instanceId(); this.schematicsDirectory = repository.getSchematicsDirectory(instanceId); refresh(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java index fe02c80c66c..027b32781e2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java @@ -37,6 +37,7 @@ import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.World; import org.jackhuang.hmcl.task.Schedulers; @@ -89,7 +90,9 @@ protected Skin createDefaultSkin() { } @Override - public void loadInstance(HMCLGameRepository repository, @Nullable GameInstanceID instanceId) { + public void loadInstance(HMCLGameInstance.Optional instance) { + HMCLGameRepository repository = instance.repository(); + @Nullable GameInstanceID instanceId = instance.instanceId(); this.repository = repository; this.instanceId = instanceId; this.savesDir = repository.getSavesDirectory(instanceId); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java index fcc89fb16d9..32ce279c62e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java @@ -25,7 +25,9 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; +import java.util.Objects; import org.jackhuang.hmcl.game.World; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -54,8 +56,7 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final World world; private final Path backupsDir; - private final HMCLGameRepository repository; - private final GameInstanceID instanceId; + private final HMCLGameInstance gameInstance; private final boolean supportQuickPlay; private FileChannel sessionLockChannel; @@ -71,10 +72,14 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final TabHeader.Tab dataPackTab = new TabHeader.Tab<>("dataPackListPage"); public WorldManagePage(World world, HMCLGameRepository repository, GameInstanceID instanceId) { + this(world, Objects.requireNonNull(repository.findInstance(instanceId), + () -> "Instance not found: " + instanceId)); + } + + public WorldManagePage(World world, HMCLGameInstance gameInstance) { this.world = world; - this.backupsDir = repository.getBackupsDirectory(instanceId); - this.repository = repository; - this.instanceId = instanceId; + this.gameInstance = gameInstance; + this.backupsDir = gameInstance.getRepository().getBackupsDirectory(gameInstance.getId()); updateSessionLockChannel(); @@ -91,7 +96,7 @@ public WorldManagePage(World world, HMCLGameRepository repository, GameInstanceI this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true)); - Optional gameVersion = repository.getGameVersion(instanceId); + Optional gameVersion = gameInstance.getRepository().getGameVersion(gameInstance.getId()); supportQuickPlay = World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion)); this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited); @@ -151,11 +156,11 @@ public void onExited(Navigator.NavigationEvent event) { public void launch() { fireEvent(new PageCloseEvent()); - Instances.launchAndEnterWorld(repository, instanceId, world.getFileName()); + Instances.launchAndEnterWorld(gameInstance.getRepository(), gameInstance.getId(), world.getFileName()); } public void generateLaunchScript() { - Instances.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName()); + Instances.generateLaunchScriptForQuickEnterWorld(gameInstance.getRepository(), gameInstance.getId(), world.getFileName()); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java index 0de3963470d..544471ba756 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java @@ -20,6 +20,7 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.ui.FXUtils; @@ -59,7 +60,7 @@ public LauncherSettingsPage() { tab = new TabHeader(transitionPane, gameTab, javaManagementTab, settingsTab, personalizationTab, downloadTab, helpTab, feedbackTab, aboutTab); tab.select(gameTab); - addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(GameDirectoryManager.getSelectedRepository(), null)); + addEventHandler(Navigator.NavigationEvent.NAVIGATED, event -> gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()))); AdvancedListBox sideBar = new AdvancedListBox() .addNavigationDrawerTab(tab, gameTab, i18n("settings.type.global.manage"), SVG.STADIA_CONTROLLER, SVG.STADIA_CONTROLLER_FILL) @@ -89,7 +90,7 @@ public void onPageHidden() { } public void showGameSettings(HMCLGameRepository repository) { - gameTab.getNode().loadInstance(repository, null); + gameTab.getNode().loadInstance(HMCLGameInstance.Optional.empty(repository)); tab.select(gameTab, false); } From e3bc3b1b086291acce2b84addf468d499bd490e6 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 02:48:28 +0800 Subject: [PATCH 025/199] Refactor game instance handling to use HMCLGameInstance directly for improved clarity and consistency --- .../jackhuang/hmcl/game/LauncherHelper.java | 22 ++-- .../hmcl/ui/instances/GameInstancePage.java | 61 ++++++++-- .../jackhuang/hmcl/ui/instances/GameItem.java | 7 +- .../hmcl/ui/instances/GameListItem.java | 20 ++-- .../hmcl/ui/instances/GameListPage.java | 7 +- .../hmcl/ui/instances/GameListPopupMenu.java | 6 +- .../hmcl/ui/instances/InstallerListPage.java | 38 +++++-- .../hmcl/ui/instances/Instances.java | 106 ++++++++++++------ .../hmcl/ui/instances/ModListPage.java | 31 ++--- .../ui/instances/ResourcePackListPage.java | 38 ++++--- .../hmcl/ui/instances/SchematicsPage.java | 7 +- .../hmcl/ui/instances/WorldListPage.java | 37 +++--- .../hmcl/ui/instances/WorldManagePage.java | 11 +- 13 files changed, 251 insertions(+), 140 deletions(-) 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 4b3dbeebc80..b62242ee36a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -85,9 +85,7 @@ public final class LauncherHelper { private static final String LWJGL_3_4_1_TIP = "lwjgl3.4.1-ffm"; private final HMCLGameInstance gameInstance; - private final HMCLGameRepository repository; private Account account; - private final GameInstanceID selectedInstanceId; private Path scriptFile; private final GameSettings.Effective setting; private LauncherVisibility launcherVisibility; @@ -97,10 +95,8 @@ public final class LauncherHelper { public LauncherHelper(HMCLGameInstance gameInstance, Account account) { this.gameInstance = Objects.requireNonNull(gameInstance); - this.repository = gameInstance.getRepository(); this.account = Objects.requireNonNull(account); - this.selectedInstanceId = gameInstance.getId(); - this.setting = repository.getEffectiveGameSettings(selectedInstanceId); + this.setting = gameInstance.getRepository().getEffectiveGameSettings(gameInstance.getId()); this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty); this.showLogs = setting.getInheritable(GameSettings::showLogsProperty); this.launchingStepsPane.setTitle(i18n("instance.launch")); @@ -115,6 +111,14 @@ public HMCLGameInstance getGameInstance() { return gameInstance; } + private HMCLGameRepository repository() { + return gameInstance.getRepository(); + } + + private GameInstanceID instanceId() { + return gameInstance.getId(); + } + private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL); public Account getAccount() { @@ -145,7 +149,7 @@ public void setDisableOfflineSkin() { public void launch() { FXUtils.checkFxUserThread(); - LOG.info("Launching game version: " + selectedInstanceId); + LOG.info("Launching game version: " + instanceId()); Controllers.dialog(launchingStepsPane); launch0(); @@ -160,8 +164,10 @@ private void launch0() { // https://github.com/HMCL-dev/HMCL/pull/4121 PROCESSES.removeIf(it -> it.get() == null); + HMCLGameRepository repository = repository(); + GameInstanceID selectedInstanceId = instanceId(); DefaultDependencyManager dependencyManager = repository.getDependency(); - AtomicReference version = new AtomicReference<>(MaintainTask.maintain(repository, repository.getResolvedInstanceManifest(selectedInstanceId).launchManifest())); + AtomicReference version = new AtomicReference<>(MaintainTask.maintain(repository, gameInstance.getResolvedManifest().launchManifest())); Optional gameVersion = repository.getGameVersion(version.get()); boolean integrityCheck = repository.unmarkInstanceLaunchedAbnormally(selectedInstanceId); CountDownLatch launchingLatch = new CountDownLatch(1); @@ -288,7 +294,7 @@ private void launch0() { LaunchOptions launchOptions = launchOptionsBuilder.create(); - LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(repository.getModsDirectory(selectedInstanceId), 10)); + LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10)); return new HMCLGameLauncher( repository, diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index 89b56c9c1c7..37c44d861c9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -173,7 +173,8 @@ public void loadInstance(GameInstanceID instanceId, HMCLGameRepository repositor worldListTab.getNode().loadInstance(current); if (schematicsTab.isInitialized()) schematicsTab.getNode().loadInstance(current); - currentInstanceUpgradable.set(repository.isModpack(instanceId)); + HMCLGameInstance gameInstance = current.instance(); + currentInstanceUpgradable.set(gameInstance != null && repository.isModpack(gameInstance.getId())); } private void onNavigated(Navigator.NavigationEvent event) { @@ -192,11 +193,18 @@ private void onNavigated(Navigator.NavigationEvent event) { } private void onBrowse(String sub) { - FXUtils.openFolder(getRepository().getRunDirectory(getInstanceId()).resolve(sub)); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance == null) { + return; + } + FXUtils.openFolder(gameInstance.getRunDirectory().resolve(sub)); } private void redownloadAssetIndex() { - Instances.updateGameAssets(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.updateGameAssets(gameInstance); + } } private void clearLibraries() { @@ -231,36 +239,65 @@ private void clearAssets() { } private void clearJunkFiles() { - Instances.cleanInstance(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.cleanInstance(gameInstance); + } } private void testGame() { - Instances.testGame(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.testGame(gameInstance); + } } private void updateGame() { - Instances.updateInstance(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.updateInstance(gameInstance); + } } private void generateLaunchScript() { - Instances.generateLaunchScript(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.generateLaunchScript(gameInstance); + } } private void export() { - Instances.exportInstance(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.exportInstance(gameInstance); + } } private void rename() { - Instances.renameInstance(getRepository(), getInstanceId()) - .thenApply(newInstanceId -> this.preferredInstanceId = new GameInstanceID(newInstanceId)); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.renameInstance(gameInstance) + .thenApply(newInstanceId -> this.preferredInstanceId = new GameInstanceID(newInstanceId)); + } } private void remove() { - Instances.deleteInstance(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.deleteInstance(gameInstance); + } } private void duplicate() { - Instances.duplicateInstance(getRepository(), getInstanceId()); + HMCLGameInstance gameInstance = requireGameInstance(); + if (gameInstance != null) { + Instances.duplicateInstance(gameInstance); + } + } + + private @Nullable HMCLGameInstance requireGameInstance() { + HMCLGameInstance.Optional current = instance.get(); + return current != null ? current.instance() : null; } public HMCLGameRepository getRepository() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java index 891de9fe3a8..a517c88e648 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java @@ -27,11 +27,11 @@ import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.util.i18n.I18n; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.util.Objects; -import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; @@ -96,7 +96,8 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { CompletableFuture.supplyAsync(() -> { // GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms) - Optional gameVersion = gameInstance.getRepository().getGameVersion(gameInstance.getId()); + GameVersionNumber version = gameInstance.getVersion(); + String gameVersion = version == GameVersionNumber.unknown() ? null : version.toString(); String modPackVersion = null; try { ModpackConfiguration config = gameInstance.getRepository().readModpackConfiguration(gameInstance.getId()); @@ -104,7 +105,7 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { } catch (IOException e) { LOG.warning("Failed to read modpack configuration from " + getId(), e); } - return new Result(gameVersion.orElse(null), modPackVersion); + return new Result(gameVersion, modPackVersion); }, POOL_VERSION_RESOLVE).whenCompleteAsync((result, exception) -> { if (exception == null) { if (result.tag != null) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java index 4366b4c9772..01eb09fcbe4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java @@ -56,39 +56,39 @@ public ReadOnlyBooleanProperty selectedProperty() { } public void rename() { - Instances.renameInstance(getRepository(), getInstanceId()); + Instances.renameInstance(gameInstance); } public void duplicate() { - Instances.duplicateInstance(getRepository(), getInstanceId()); + Instances.duplicateInstance(gameInstance); } public void remove() { - Instances.deleteInstance(getRepository(), getInstanceId()); + Instances.deleteInstance(gameInstance); } public void export() { - Instances.exportInstance(getRepository(), getInstanceId()); + Instances.exportInstance(gameInstance); } public void browse() { - Instances.openFolder(getRepository(), getInstanceId()); + Instances.openFolder(gameInstance); } public void testGame() { - Instances.testGame(getRepository(), getInstanceId()); + Instances.testGame(gameInstance); } public void launch() { - Instances.launch(getRepository(), getInstanceId()); + Instances.launch(gameInstance); } public void modifyGameSettings() { - Instances.modifyGameSettings(getRepository(), getInstanceId()); + Instances.modifyGameSettings(gameInstance); } public void generateLaunchScript() { - Instances.generateLaunchScript(getRepository(), getInstanceId()); + Instances.generateLaunchScript(gameInstance); } public boolean canUpdate() { @@ -96,6 +96,6 @@ public boolean canUpdate() { } public void update() { - Instances.updateInstance(getRepository(), getInstanceId()); + Instances.updateInstance(gameInstance); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java index ae369f609c3..39cb26de4cc 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java @@ -66,6 +66,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Locale; +import java.util.Objects; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -156,7 +157,11 @@ private void loadVersions(HMCLGameRepository repository) { setLoading(true); setFailedReason(null); - List versionItems = repository.getDisplayInstanceManifests().map(instance -> new GameListItem(repository, instance.id())).toList(); + List versionItems = repository.getDisplayInstanceManifests() + .map(manifest -> repository.findInstance(manifest.id())) + .filter(Objects::nonNull) + .map(GameListItem::new) + .toList(); sourceList.setAll(versionItems); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java index b95dd77da72..8ed14395626 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java @@ -43,6 +43,7 @@ import org.jackhuang.hmcl.util.StringUtils; import java.util.List; +import java.util.Objects; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -62,8 +63,9 @@ public static JFXPopup showAndGetPopup(Node owner, JFXPopup.PopupVPosition vAlig HMCLGameRepository repository, List versions) { GameListPopupMenu menu = new GameListPopupMenu(); menu.getItems().setAll(versions.stream() - .filter(it -> repository.hasInstance(it.id())) - .map(it -> new GameItem(repository, it.id())) + .map(it -> repository.findInstance(it.id())) + .filter(Objects::nonNull) + .map(GameItem::new) .toList()); JFXPopup popup = new JFXPopup(menu); popup.show(owner, vAlign, hAlign, initOffsetX, initOffsetY); 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 83821c3f480..207eaf52879 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 @@ -22,7 +22,6 @@ import javafx.scene.control.Skin; import javafx.stage.FileChooser; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; @@ -46,8 +45,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class InstallerListPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable { - private HMCLGameRepository repository; - private GameInstanceID instanceId; + private @Nullable HMCLGameInstance gameInstance; private GameInstanceManifest manifest; private String gameVersion; @@ -65,17 +63,22 @@ protected Skin createDefaultSkin() { @Override public void loadInstance(HMCLGameInstance.Optional instance) { - HMCLGameRepository repository = instance.repository(); - @Nullable GameInstanceID instanceId = instance.instanceId(); - this.repository = repository; - this.instanceId = instanceId; - this.manifest = repository.getInstanceManifest(instanceId); + this.gameInstance = instance.instance(); + if (gameInstance == null) { + itemsProperty().clear(); + this.manifest = null; + this.gameVersion = null; + return; + } + + HMCLGameRepository repository = gameInstance.getRepository(); + this.manifest = gameInstance.getManifest(); this.gameVersion = null; CompletableFuture.supplyAsync(() -> { gameVersion = repository.getGameVersion(manifest).orElse(null); - return LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); + return LibraryAnalyzer.analyze(gameInstance.getResolvedManifest(), gameVersion); }).thenAcceptAsync(analyzer -> { itemsProperty().clear(); @@ -109,7 +112,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> loadInstance(HMCLGameInstance.Optional.of(this.repository, this.instanceId))) + .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) .start()); itemsProperty().add(item); @@ -131,7 +134,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> loadInstance(HMCLGameInstance.Optional.of(this.repository, this.instanceId))) + .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) .start()); itemsProperty().add(installerItem); @@ -139,6 +142,12 @@ public void loadInstance(HMCLGameInstance.Optional instance) { }, Platform::runLater); } + private void reloadCurrentInstance() { + if (gameInstance != null) { + loadInstance(HMCLGameInstance.Optional.of(gameInstance.getRepository(), gameInstance.getId())); + } + } + public void installOffline() { FileChooser chooser = new FileChooser(); chooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("extension.modloader.installer"), "*.jar", "*.exe")); @@ -147,6 +156,11 @@ public void installOffline() { } private void doInstallOffline(Path file) { + if (gameInstance == null || manifest == null) { + return; + } + + HMCLGameRepository repository = gameInstance.getRepository(); Task task = repository.getDependency().installLibraryAsync(manifest, file) .thenComposeAsync(repository::saveAsync) .thenComposeAsync(repository.refreshAsync()); @@ -156,7 +170,7 @@ private void doInstallOffline(Path file) { public void onStop(boolean success, TaskExecutor executor) { runInFX(() -> { if (success) { - loadInstance(HMCLGameInstance.Optional.of(repository, instanceId)); + reloadCurrentInstance(); Controllers.dialog(i18n("install.success")); } else { if (executor.getException() == null) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java index 3b7b3291eda..494a77c6988 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 @@ -115,9 +115,11 @@ public static void downloadModpackImpl(DownloadProvider downloadProvider, HMCLGa ); } - public static void deleteInstance(HMCLGameRepository repository, GameInstanceID instanceId) { - boolean isIndependent = repository.getRunDirectory(instanceId).toAbsolutePath().normalize() - .equals(repository.getLayout().getInstanceRoot(instanceId).toAbsolutePath().normalize()); + public static void deleteInstance(HMCLGameInstance gameInstance) { + HMCLGameRepository repository = gameInstance.getRepository(); + GameInstanceID instanceId = gameInstance.getId(); + boolean isIndependent = gameInstance.getRunDirectory().toAbsolutePath().normalize() + .equals(gameInstance.getInstanceRoot().toAbsolutePath().normalize()); String message = isIndependent ? i18n("instance.manage.remove.confirm.independent", instanceId) : i18n("instance.manage.remove.confirm.trash", instanceId, instanceId + "_removed"); @@ -135,7 +137,9 @@ public static void deleteInstance(HMCLGameRepository repository, GameInstanceID Controllers.confirmAction(message, i18n("message.warning"), MessageDialogPane.MessageType.WARNING, deleteButton); } - public static CompletableFuture renameInstance(HMCLGameRepository repository, GameInstanceID instanceId) { + public static CompletableFuture renameInstance(HMCLGameInstance gameInstance) { + HMCLGameRepository repository = gameInstance.getRepository(); + GameInstanceID instanceId = gameInstance.getId(); return Controllers.prompt(i18n("instance.manage.rename.message"), (newName, handler) -> { if (newName.equals(instanceId.toString())) { handler.resolve(); @@ -159,12 +163,12 @@ public static CompletableFuture renameInstance(HMCLGameRepository reposi new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName) || newVersionName.equals(instanceId.toString()))); } - public static void exportInstance(HMCLGameRepository repository, GameInstanceID instanceId) { - Controllers.getDecorator().startWizard(new ExportWizardProvider(repository, instanceId), i18n("modpack.wizard")); + public static void exportInstance(HMCLGameInstance gameInstance) { + Controllers.getDecorator().startWizard(new ExportWizardProvider(gameInstance), i18n("modpack.wizard")); } - public static void openFolder(HMCLGameRepository repository, GameInstanceID instanceId) { - FXUtils.openFolder(repository.getRunDirectory(instanceId)); + public static void openFolder(HMCLGameInstance gameInstance) { + FXUtils.openFolder(gameInstance.getRunDirectory()); } public static void installFromJson(HMCLGameRepository repository, Path file) { @@ -208,7 +212,9 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { }, FileUtils.getNameWithoutExtension(file), new Validator(i18n("install.new_game.malformed"), HMCLGameRepository::isValidInstanceId), new Validator(i18n("install.new_game.already_exists"), newVersionName -> !repository.instanceIdConflicts(newVersionName))); } - public static void duplicateInstance(HMCLGameRepository repository, GameInstanceID instanceId) { + public static void duplicateInstance(HMCLGameInstance gameInstance) { + HMCLGameRepository repository = gameInstance.getRepository(); + GameInstanceID instanceId = gameInstance.getId(); Controllers.prompt( new PromptDialogPane.Builder(i18n("instance.manage.duplicate.prompt"), (res, handler) -> { String newInstanceName = ((PromptDialogPane.Builder.StringQuestion) res.get(1)).getValue(); @@ -234,33 +240,35 @@ public static void duplicateInstance(HMCLGameRepository repository, GameInstance .addQuestion(new PromptDialogPane.Builder.BooleanQuestion(i18n("instance.manage.duplicate.duplicate_save"), false))); } - public static void updateInstance(HMCLGameRepository repository, GameInstanceID instanceId) { - Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(repository, instanceId)); + public static void updateInstance(HMCLGameInstance gameInstance) { + Controllers.getDecorator().startWizard(new ModpackInstallWizardProvider(gameInstance.getRepository(), gameInstance.getId())); } - public static void updateGameAssets(HMCLGameRepository repository, GameInstanceID instanceId) { - TaskExecutor executor = new GameAssetDownloadTask(repository.getDependency(), repository.getInstanceManifest(instanceId), GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true) - .executor(); + public static void updateGameAssets(HMCLGameInstance gameInstance) { + TaskExecutor executor = new GameAssetDownloadTask( + gameInstance.getRepository().getDependency(), + gameInstance.getManifest(), + GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, + true).executor(); Controllers.taskDialog(executor, i18n("instance.manage.redownload_assets_index"), TaskCancellationAction.NO_CANCEL); executor.start(); } - public static void cleanInstance(HMCLGameRepository repository, GameInstanceID instanceId) { + public static void cleanInstance(HMCLGameInstance gameInstance) { try { - repository.clean(instanceId); + gameInstance.getRepository().clean(gameInstance.getId()); } catch (IOException e) { LOG.warning("Unable to clean game directory", e); } } @SafeVarargs - public static void generateLaunchScript(HMCLGameRepository repository, GameInstanceID instanceId, Consumer... injecters) { - if (!checkVersionForLaunching(repository, instanceId)) - return; + public static void generateLaunchScript(HMCLGameInstance gameInstance, Consumer... injecters) { ensureSelectedAccount(account -> { + Path runDirectory = gameInstance.getRunDirectory(); FileChooser chooser = new FileChooser(); - if (Files.isDirectory(repository.getRunDirectory(instanceId))) - chooser.setInitialDirectory(repository.getRunDirectory(instanceId).toFile()); + if (Files.isDirectory(runDirectory)) + chooser.setInitialDirectory(runDirectory.toFile()); chooser.setTitle(i18n("instance.launch_script.save")); if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { chooser.getExtensionFilters().add( @@ -278,7 +286,7 @@ public static void generateLaunchScript(HMCLGameRepository repository, GameInsta file = file.resolveSibling(file.getFileName().toString() + "." + defaultExt); } - LauncherHelper launcherHelper = new LauncherHelper(repository, account, instanceId); + LauncherHelper launcherHelper = new LauncherHelper(gameInstance, account); for (Consumer injecter : injecters) { injecter.accept(launcherHelper); } @@ -287,6 +295,15 @@ public static void generateLaunchScript(HMCLGameRepository repository, GameInsta }); } + /// Resolves the selected instance (which may be missing) and generates a launch script. + @SafeVarargs + public static void generateLaunchScript(HMCLGameRepository repository, GameInstanceID instanceId, Consumer... injecters) { + HMCLGameInstance gameInstance = resolveLaunchInstance(repository, instanceId); + if (gameInstance != null) { + generateLaunchScript(gameInstance, injecters); + } + } + private static boolean isValidScriptExtension(String ext) { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { return ext.equalsIgnoreCase("bat") || ext.equalsIgnoreCase("ps1"); @@ -303,11 +320,9 @@ private static String getDefaultScriptExtension() { } @SafeVarargs - public static void launch(HMCLGameRepository repository, GameInstanceID instanceId, Consumer... injecters) { - if (!checkVersionForLaunching(repository, instanceId)) - return; + public static void launch(HMCLGameInstance gameInstance, Consumer... injecters) { ensureSelectedAccount(account -> { - LauncherHelper launcherHelper = new LauncherHelper(repository, account, instanceId); + LauncherHelper launcherHelper = new LauncherHelper(gameInstance, account); for (Consumer injecter : injecters) { injecter.accept(launcherHelper); } @@ -315,20 +330,36 @@ public static void launch(HMCLGameRepository repository, GameInstanceID instance }); } - public static void testGame(HMCLGameRepository repository, GameInstanceID instanceId) { - launch(repository, instanceId, LauncherHelper::setTestMode); + /// Resolves the selected instance (which may be missing) and launches it. + @SafeVarargs + public static void launch(HMCLGameRepository repository, GameInstanceID instanceId, Consumer... injecters) { + HMCLGameInstance gameInstance = resolveLaunchInstance(repository, instanceId); + if (gameInstance != null) { + launch(gameInstance, injecters); + } + } + + public static void testGame(HMCLGameInstance gameInstance) { + launch(gameInstance, LauncherHelper::setTestMode); } - public static void launchAndEnterWorld(HMCLGameRepository repository, GameInstanceID instanceId, String worldFolderName) { - launch(repository, instanceId, launcherHelper -> + public static void launchAndEnterWorld(HMCLGameInstance gameInstance, String worldFolderName) { + launch(gameInstance, launcherHelper -> launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName))); } - public static void generateLaunchScriptForQuickEnterWorld(HMCLGameRepository repository, GameInstanceID instanceId, String worldFolderName) { - generateLaunchScript(repository, instanceId, launcherHelper -> + public static void generateLaunchScriptForQuickEnterWorld(HMCLGameInstance gameInstance, String worldFolderName) { + generateLaunchScript(gameInstance, launcherHelper -> launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName))); } + private static HMCLGameInstance resolveLaunchInstance(HMCLGameRepository repository, GameInstanceID instanceId) { + if (!checkVersionForLaunching(repository, instanceId)) { + return null; + } + return repository.findInstance(instanceId); + } + private static boolean checkVersionForLaunching(HMCLGameRepository repository, GameInstanceID instanceId) { boolean unavailable; if (instanceId == null || !repository.isLoaded()) { @@ -387,10 +418,17 @@ public static void modifyGlobalSettings(HMCLGameRepository repository) { Controllers.navigate(Controllers.getSettingsPage()); } - public static void modifyGameSettings(HMCLGameRepository repository, GameInstanceID instanceId) { - Controllers.getGameInstancePage().setInstance(instanceId, repository); + public static void modifyGameSettings(HMCLGameInstance gameInstance) { + Controllers.getGameInstancePage().setInstance(gameInstance.getId(), gameInstance.getRepository()); Controllers.getGameInstancePage().showInstanceSettings(); // VersionPage.loadVersion will be invoked after navigation Controllers.navigate(Controllers.getGameInstancePage()); } + + public static void modifyGameSettings(HMCLGameRepository repository, GameInstanceID instanceId) { + HMCLGameInstance gameInstance = repository.findInstance(instanceId); + if (gameInstance != null) { + modifyGameSettings(gameInstance); + } + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index a77d39b2b4d..8a17626dbbe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -56,8 +56,7 @@ public final class ModListPage extends ListPageBase supportedLoaders = EnumSet.noneOf(ModLoaderType.class); @@ -86,15 +85,13 @@ public void refresh() { @Override public void loadInstance(HMCLGameInstance.Optional instance) { - this.repository = instance.repository(); - this.instanceId = instance.instanceId(); - HMCLGameInstance gameInstance = instance.instance(); + this.gameInstance = instance.instance(); if (gameInstance == null) { return; } GameInstanceManifest resolved = gameInstance.getResolvedManifest().standaloneManifest(); - this.gameVersion = repository.getGameVersion(resolved).orElse(null); + this.gameVersion = gameInstance.getRepository().getGameVersion(resolved).orElse(null); loadMods(gameInstance.getModManager()); } @@ -240,18 +237,21 @@ void disableSelected(ObservableList selectedItems } public void openModFolder() { - FXUtils.openFolder(repository.getRunDirectory(instanceId).resolve("mods")); + if (gameInstance != null) { + FXUtils.openFolder(gameInstance.getModsDirectory()); + } } public void checkUpdates(Collection mods) { Objects.requireNonNull(mods); - if (isLoading()) { + if (isLoading() || gameInstance == null) { return; } + HMCLGameInstance gameInstance = this.gameInstance; Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { - Optional gameVersion = repository.getGameVersion(instanceId); + Optional gameVersion = gameInstance.getRepository().getGameVersion(gameInstance.getId()); return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, mods)).orElse(null); }) .whenComplete(Schedulers.javafx(), (result, exception) -> { @@ -267,7 +267,7 @@ public void checkUpdates(Collection mods) { .withStagesHints("update.checking"), i18n("addon.check_update"), TaskCancellationAction.NORMAL); - if (repository.isModpack(instanceId)) { + if (gameInstance.getRepository().isModpack(gameInstance.getId())) { Controllers.confirm( i18n("mods.update_modpack_mod.warning"), null, MessageDialogPane.MessageType.WARNING, @@ -278,7 +278,10 @@ public void checkUpdates(Collection mods) { } public void download() { - Controllers.getDownloadPage().showModDownloads().selectInstance(instanceId); + if (gameInstance == null) { + return; + } + Controllers.getDownloadPage().showModDownloads().selectInstance(gameInstance.getId()); Controllers.navigate(Controllers.getDownloadPage()); } @@ -292,14 +295,14 @@ public void rollback(LocalModFile from, LocalModFile to) { } public GameDirectory getGameDirectory() { - return this.repository.getGameDirectory(); + return gameInstance != null ? gameInstance.getRepository().getGameDirectory() : null; } public HMCLGameRepository getRepository() { - return this.repository; + return gameInstance != null ? gameInstance.getRepository() : null; } public GameInstanceID getInstanceId() { - return this.instanceId; + return gameInstance != null ? gameInstance.getId() : null; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java index 719b5a5ccc4..acac9f27c6b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java @@ -43,10 +43,9 @@ import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; import org.jackhuang.hmcl.addon.resourcepack.ResourcePackFile; import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.DownloadProviders; +import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -91,8 +90,7 @@ public final class ResourcePackListPage extends ListPageBase createDefaultSkin() { @Override public void loadInstance(HMCLGameInstance.Optional instance) { - HMCLGameRepository repository = instance.repository(); - @Nullable GameInstanceID instanceId = instance.instanceId(); - this.repository = repository; - this.instanceId = instanceId; - this.resourcePackManager = new ResourcePackManager(repository, instanceId); + this.gameInstance = instance.instance(); + if (gameInstance == null) { + this.resourcePackManager = null; + this.resourcePackDirectory = null; + getItems().clear(); + return; + } + + this.resourcePackManager = gameInstance.getResourcePackManager(); this.resourcePackDirectory = this.resourcePackManager.getDirectory(); refresh(); @@ -188,7 +190,10 @@ public void onAddFiles() { } private void onDownload() { - Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(instanceId); + if (gameInstance == null) { + return; + } + Controllers.getDownloadPage().showResourcePackDownloads().selectInstance(gameInstance.getId()); Controllers.navigate(Controllers.getDownloadPage()); } @@ -235,9 +240,14 @@ private void removeSelected(List selectedItems) { } public void checkUpdates(Collection resourcePacks) { + HMCLGameInstance gameInstance = this.gameInstance; + if (gameInstance == null) { + return; + } + Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { - Optional gameVersion = repository.getGameVersion(instanceId); + Optional gameVersion = gameInstance.getRepository().getGameVersion(gameInstance.getId()); return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, resourcePacks)).orElse(null); }) .whenComplete(Schedulers.javafx(), (result, exception) -> { @@ -252,7 +262,7 @@ public void checkUpdates(Collection resourcePacks) { .withStagesHints("update.checking"), i18n("addon.check_update"), TaskCancellationAction.NORMAL); - if (repository.isModpack(instanceId)) { + if (gameInstance.getRepository().isModpack(gameInstance.getId())) { Controllers.confirm( i18n("resourcepack.update_in_modpack.warning"), null, MessageDialogPane.MessageType.WARNING, @@ -648,7 +658,9 @@ private static final class ResourcePackInfoDialog extends JFXDialogLayout { ? HMCLLocalizedDownloadListPage.ofCurseForgeResourcePack(null, false) : HMCLLocalizedDownloadListPage.ofModrinthResourcePack(null, false), remoteAddon, - HMCLGameInstance.Optional.of(page.repository, page.instanceId), + page.gameInstance != null + ? HMCLGameInstance.Optional.of(page.gameInstance) + : HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository()), org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK )); }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java index 9dc3a4a9d25..ac27b1915db 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java @@ -34,9 +34,7 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.schematic.LitematicFile; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -90,9 +88,8 @@ protected Skin createDefaultSkin() { @Override public void loadInstance(HMCLGameInstance.Optional instance) { - HMCLGameRepository repository = instance.repository(); - @Nullable GameInstanceID instanceId = instance.instanceId(); - this.schematicsDirectory = repository.getSchematicsDirectory(instanceId); + HMCLGameInstance gameInstance = instance.instance(); + this.schematicsDirectory = gameInstance != null ? gameInstance.getSchematicsDirectory() : null; refresh(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java index 027b32781e2..80c4aa03abe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java @@ -36,9 +36,7 @@ import javafx.scene.layout.HBox; import javafx.scene.layout.StackPane; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.World; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -57,7 +55,6 @@ import java.time.Instant; import java.util.Arrays; import java.util.List; -import java.util.Optional; import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes; @@ -70,8 +67,7 @@ public final class WorldListPage extends ListPageBase implements GameInst private Path savesDir; private List worlds; - private HMCLGameRepository repository; - private GameInstanceID instanceId; + private @Nullable HMCLGameInstance gameInstance; private final BooleanProperty supportQuickPlay = new SimpleBooleanProperty(this, "supportQuickPlay", false); private int refreshCount = 0; @@ -91,21 +87,18 @@ protected Skin createDefaultSkin() { @Override public void loadInstance(HMCLGameInstance.Optional instance) { - HMCLGameRepository repository = instance.repository(); - @Nullable GameInstanceID instanceId = instance.instanceId(); - this.repository = repository; - this.instanceId = instanceId; - this.savesDir = repository.getSavesDirectory(instanceId); + this.gameInstance = instance.instance(); + this.savesDir = gameInstance != null ? gameInstance.getSavesDirectory() : null; refresh(); } private void updateWorldList() { - if (worlds == null) { + if (worlds == null || gameInstance == null) { getItems().clear(); } else if (showAll.get()) { getItems().setAll(worlds); } else { - GameVersionNumber gameVersion = repository.getGameVersion(instanceId).map(GameVersionNumber::asGameVersion).orElse(null); + GameVersionNumber gameVersion = gameInstance.getVersion(); getItems().setAll(worlds.stream() .filter(world -> world.getGameVersion() == null || world.getGameVersion().equals(gameVersion)) .toList()); @@ -113,15 +106,16 @@ private void updateWorldList() { } public void refresh() { - if (repository == null || instanceId == null) + if (gameInstance == null || savesDir == null) return; int currentRefresh = ++refreshCount; + HMCLGameInstance gameInstance = this.gameInstance; setLoading(true); Task.supplyAsync(Schedulers.io(), () -> { // Ensure the game version number is parsed - repository.getGameVersion(instanceId); + gameInstance.getVersion(); return World.getWorlds(savesDir); }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (refreshCount != currentRefresh) { @@ -129,8 +123,7 @@ public void refresh() { return; } - Optional gameVersion = repository.getGameVersion(instanceId); - supportQuickPlay.set(World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion))); + supportQuickPlay.set(World.supportQuickPlay(gameInstance.getVersion())); worlds = result; updateWorldList(); @@ -183,7 +176,9 @@ else if (e instanceof IOException && e.getCause() instanceof InvalidPathExceptio } private void showManagePage(World world) { - Controllers.navigate(new WorldManagePage(world, repository, instanceId)); + if (gameInstance != null) { + Controllers.navigate(new WorldManagePage(world, gameInstance)); + } } public void export(World world) { @@ -203,11 +198,15 @@ public void reveal(World world) { } public void launch(World world) { - Instances.launchAndEnterWorld(repository, instanceId, world.getFileName()); + if (gameInstance != null) { + Instances.launchAndEnterWorld(gameInstance, world.getFileName()); + } } public void generateLaunchScript(World world) { - Instances.generateLaunchScriptForQuickEnterWorld(repository, instanceId, world.getFileName()); + if (gameInstance != null) { + Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName()); + } } public BooleanProperty showAllProperty() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java index 32ce279c62e..3bea915abfd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java @@ -38,13 +38,11 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.util.ChunkBaseApp; import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNull; import java.io.IOException; import java.nio.channels.FileChannel; import java.nio.file.Path; -import java.util.Optional; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -79,7 +77,7 @@ public WorldManagePage(World world, HMCLGameRepository repository, GameInstanceI public WorldManagePage(World world, HMCLGameInstance gameInstance) { this.world = world; this.gameInstance = gameInstance; - this.backupsDir = gameInstance.getRepository().getBackupsDirectory(gameInstance.getId()); + this.backupsDir = gameInstance.getBackupsDirectory(); updateSessionLockChannel(); @@ -96,8 +94,7 @@ public WorldManagePage(World world, HMCLGameInstance gameInstance) { this.state = new SimpleObjectProperty<>(new State(i18n("world.manage.title", StringUtils.parseColorEscapes(world.getWorldName())), null, true, true, true)); - Optional gameVersion = gameInstance.getRepository().getGameVersion(gameInstance.getId()); - supportQuickPlay = World.supportQuickPlay(GameVersionNumber.asGameVersion(gameVersion)); + supportQuickPlay = World.supportQuickPlay(gameInstance.getVersion()); this.addEventHandler(Navigator.NavigationEvent.EXITED, this::onExited); this.addEventHandler(Navigator.NavigationEvent.NAVIGATED, this::onNavigated); @@ -156,11 +153,11 @@ public void onExited(Navigator.NavigationEvent event) { public void launch() { fireEvent(new PageCloseEvent()); - Instances.launchAndEnterWorld(gameInstance.getRepository(), gameInstance.getId(), world.getFileName()); + Instances.launchAndEnterWorld(gameInstance, world.getFileName()); } public void generateLaunchScript() { - Instances.generateLaunchScriptForQuickEnterWorld(gameInstance.getRepository(), gameInstance.getId(), world.getFileName()); + Instances.generateLaunchScriptForQuickEnterWorld(gameInstance, world.getFileName()); } @Override From 2893eb84ac9e7fb7d5a89b487b98cb7d7ed8c49f Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 18:45:13 +0800 Subject: [PATCH 026/199] Refactor modpack completion tasks to use DefaultGameInstance for improved consistency and clarity --- .../hmcl/game/HMCLModpackProvider.java | 7 +- .../jackhuang/hmcl/game/LauncherHelper.java | 4 +- .../jackhuang/hmcl/game/ModpackHelper.java | 8 +- .../hmcl/ui/export/ExportWizardProvider.java | 34 +-- .../hmcl/game/DefaultGameInstance.java | 35 +-- .../hmcl/game/DefaultGameRepository.java | 38 ++-- .../hmcl/modpack/ModpackProvider.java | 46 +++- .../hmcl/modpack/ModpackUpdateTask.java | 50 +++- .../modpack/curse/CurseCompletionTask.java | 96 ++++---- .../hmcl/modpack/curse/CurseInstallTask.java | 4 +- .../modpack/curse/CurseModpackProvider.java | 9 +- .../mcbbs/McbbsModpackCompletionTask.java | 66 ++++-- .../modpack/mcbbs/McbbsModpackExportTask.java | 39 +++- .../mcbbs/McbbsModpackLocalInstallTask.java | 5 +- .../modpack/mcbbs/McbbsModpackProvider.java | 10 +- .../mcbbs/McbbsModpackRemoteInstallTask.java | 5 +- .../modrinth/ModrinthCompletionTask.java | 66 +++--- .../modpack/modrinth/ModrinthInstallTask.java | 4 +- .../modrinth/ModrinthModpackExportTask.java | 60 +++-- .../modrinth/ModrinthModpackProvider.java | 9 +- .../multimc/MultiMCModpackExportTask.java | 49 ++-- .../multimc/MultiMCModpackProvider.java | 8 +- .../server/ServerModpackCompletionTask.java | 68 ++++-- .../server/ServerModpackExportTask.java | 39 +++- .../modpack/server/ServerModpackProvider.java | 10 +- .../ServerModpackRemoteInstallTask.java | 5 +- .../hmcl/game/DefaultGameInstanceTest.java | 213 ++++++++++++++++++ 27 files changed, 718 insertions(+), 269 deletions(-) create mode 100644 HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java index b9db4d364ce..00a1233ba59 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.charset.Charset; @@ -42,12 +43,12 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { + public @Nullable Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { return null; } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof HMCLModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); @@ -55,7 +56,7 @@ public Task createUpdateTask(DefaultDependencyManager dependencyManager, Game throw new IllegalArgumentException("HMCLModpackProvider requires HMCLGameRepository"); } - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new HMCLModpackInstallTask(repository, zipFile, modpack, instanceId)); + return new ModpackUpdateTask(instance, new HMCLModpackInstallTask(repository, zipFile, modpack, instance.getId())); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index b62242ee36a..aae5f2618ee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -189,7 +189,9 @@ private void launch0() { ModpackConfiguration configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedInstanceId)); ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType()); if (provider == null) return null; - else return provider.createCompletionTask(dependencyManager, selectedInstanceId); + else return provider.createCompletionTask( + dependencyManager, + repository.getInstance(selectedInstanceId)); } catch (IOException e) { return null; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java index 03dc54ad1b2..e2fad7f1409 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java @@ -238,7 +238,9 @@ else if (modpack.getManifest() instanceof McbbsModpackManifest) public static Task getUpdateTask(HMCLGameRepository repository, ServerModpackManifest manifest, Charset charset, GameInstanceID instanceId, ModpackConfiguration configuration) throws UnsupportedModpackException { switch (configuration.getType()) { case ServerModpackRemoteInstallTask.MODPACK_TYPE: - return new ModpackUpdateTask(repository, instanceId, new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)) + return new ModpackUpdateTask( + repository.getInstance(instanceId), + new ServerModpackRemoteInstallTask(repository.getDependency(), manifest, instanceId)) .thenComposeAsync(repository.refreshAsync()) .withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries"))); default: @@ -253,11 +255,11 @@ public static Task getUpdateTask(HMCLGameRepository repository, Path zipFile, throw new UnsupportedModpackException(); } if (modpack.getManifest() instanceof MultiMCInstanceConfiguration) - return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack) + return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack) .thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId)) .thenComposeAsync(repository.refreshAsync()); else - return provider.createUpdateTask(repository.getDependency(), instanceId, zipFile, modpack) + return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack) .thenComposeAsync(repository.refreshAsync()); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java index 919ca1616c9..0fa4d64989e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java @@ -39,6 +39,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.JarUtils; import org.jackhuang.hmcl.util.io.Zipper; +import org.jetbrains.annotations.Nullable; import java.nio.file.Files; import java.nio.file.Path; @@ -76,11 +77,11 @@ public Object finish(SettingsMap settings) { } private Task exportWithLauncher(String modpackType, ModpackExportInfo exportInfo, Path modpackFile) { - Path launcherJar = JarUtils.thisJarPath(); + @Nullable Path launcherJar = JarUtils.thisJarPath(); boolean packWithLauncher = exportInfo.isPackWithLauncher() && launcherJar != null; return new Task<>() { - Path tempModpack; - Task exportTask; + @Nullable Path tempModpack; + @Nullable Task exportTask; { setSignificance(TaskSignificance.MODERATE); @@ -162,7 +163,7 @@ public void execute() throws Exception { private Task exportAsMcbbs(ModpackExportInfo exportInfo, Path modpackFile) { return new Task() { - Task dependency = null; + @Nullable Task dependency; { setSignificance(TaskSignificance.MODERATE); @@ -170,7 +171,7 @@ private Task exportAsMcbbs(ModpackExportInfo exportInfo, Path modpackFile) { @Override public void execute() { - dependency = new McbbsModpackExportTask(gameInstance.getRepository(), gameInstance.getId(), exportInfo, modpackFile); + dependency = new McbbsModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile); } @Override @@ -182,7 +183,7 @@ public Collection> getDependencies() { private Task exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) { return new Task() { - Task dependency; + @Nullable Task dependency; { setSignificance(TaskSignificance.MODERATE); @@ -190,8 +191,9 @@ private Task exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) @Override public void execute() { - GameSettings.Effective setting = gameInstance.getRepository().getEffectiveGameSettings(gameInstance.getId()); - dependency = new MultiMCModpackExportTask(gameInstance.getRepository(), gameInstance.getId(), exportInfo.getWhitelist(), + HMCLGameInstance instance = resolveCurrentGameInstance(); + GameSettings.Effective setting = instance.getRepository().getEffectiveGameSettings(instance.getId()); + dependency = new MultiMCModpackExportTask(instance, exportInfo.getWhitelist(), new MultiMCInstanceConfiguration( "OneSix", exportInfo.getName() + "-" + exportInfo.getVersion(), @@ -230,7 +232,7 @@ public Collection> getDependencies() { private Task exportAsServer(ModpackExportInfo exportInfo, Path modpackFile) { return new Task() { - Task dependency; + @Nullable Task dependency; { setSignificance(TaskSignificance.MODERATE); @@ -238,7 +240,7 @@ private Task exportAsServer(ModpackExportInfo exportInfo, Path modpackFile) { @Override public void execute() { - dependency = new ServerModpackExportTask(gameInstance.getRepository(), gameInstance.getId(), exportInfo, modpackFile); + dependency = new ServerModpackExportTask(resolveCurrentGameInstance(), exportInfo, modpackFile); } @Override @@ -250,7 +252,7 @@ public Collection> getDependencies() { private Task exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) { return new Task() { - Task dependency; + @Nullable Task dependency; { setSignificance(TaskSignificance.MODERATE); @@ -259,8 +261,7 @@ private Task exportAsModrinth(ModpackExportInfo exportInfo, Path modpackFile) @Override public void execute() { dependency = new ModrinthModpackExportTask( - gameInstance.getRepository(), - gameInstance.getId(), + resolveCurrentGameInstance(), exportInfo, modpackFile ); @@ -273,6 +274,13 @@ public Collection> getDependencies() { }; } + /// Returns the current registered snapshot for the instance selected by this wizard. + /// + /// @return the current registered instance + private HMCLGameInstance resolveCurrentGameInstance() { + return gameInstance.getRepository().getInstance(gameInstance.getId()); + } + @Override public Node createPage(WizardController controller, int step, SettingsMap settings) { return switch (step) { 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 ce2f2be3fda..443a3992706 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -25,6 +25,7 @@ import java.nio.file.Files; import java.nio.file.Path; +import java.util.Objects; import java.util.Optional; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -33,8 +34,9 @@ /// /// Index fields (`id`, `manifest`, layout binding) belong to a /// [DefaultGameRepositorySnapshot]. Session services such as [#getModManager()] and -/// [#getResourcePackManager()] are lazy and are shared across [#withNewSnapshot] / -/// [#withManifest] copies so caches survive COW publishes. +/// [#getResourcePackManager()] are lazy and are shared across copies only while the instance ID +/// and stored manifest remain unchanged, so ordinary COW publishes preserve caches without leaking +/// manifest-derived state into an updated instance. @NotNullByDefault public abstract class DefaultGameInstance implements GameInstance { @@ -68,8 +70,10 @@ protected DefaultGameInstance( this.manifest = manifest; } - /// Creates an instance that reuses session state from another wrapper of the same logical - /// instance. + /// Creates an instance that may reuse session state from another snapshot wrapper. + /// + /// Cached version and manager state is copied only when `id` and `manifest` equal those of + /// `shareSession`; otherwise the new wrapper starts with empty derived state. /// /// @param snapshot the snapshot that will own the copy /// @param id the instance id @@ -81,9 +85,11 @@ protected DefaultGameInstance( GameInstanceManifest manifest, DefaultGameInstance shareSession) { this(snapshot, id, manifest); - this.version = shareSession.version; - this.modManager = shareSession.modManager; - this.resourcePackManager = shareSession.resourcePackManager; + if (Objects.equals(id, shareSession.id) && Objects.equals(manifest, shareSession.manifest)) { + this.version = shareSession.version; + this.modManager = shareSession.modManager; + this.resourcePackManager = shareSession.resourcePackManager; + } } protected abstract DefaultGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot); @@ -149,8 +155,8 @@ public GameVersionNumber getVersion() { /// Returns the mod manager for this instance. /// - /// The manager is created on first use and shared across snapshot wrappers produced by - /// [#withNewSnapshot] / [#withManifest]. + /// The manager is created on first use and shared across snapshot wrappers whose instance ID + /// and stored manifest remain unchanged. /// /// @return the mod manager public ModManager getModManager() { @@ -162,8 +168,8 @@ public ModManager getModManager() { /// Returns the resource-pack manager for this instance. /// - /// The manager is created on first use and shared across snapshot wrappers produced by - /// [#withNewSnapshot] / [#withManifest]. + /// The manager is created on first use and shared across snapshot wrappers whose instance ID + /// and stored manifest remain unchanged. /// /// @return the resource-pack manager public ResourcePackManager getResourcePackManager() { @@ -178,8 +184,7 @@ public ResourcePackManager getResourcePackManager() { /// @return the detected version, or [GameVersionNumber#unknown()] when detection fails private GameVersionNumber detectVersion() { try { - GameInstanceManifest launchManifest = getResolvedManifest().launchManifest(); - Path jar = repository.getInstanceJar(launchManifest); + Path jar = getInstanceJarFile(); Optional detected = GameVersion.minecraftVersion(jar); if (detected.isEmpty()) { LOG.warning("Cannot find out game version of " + id @@ -201,7 +206,9 @@ public Path getInstanceRoot() { @Override public Path getInstanceJarFile() { - return layout.getInstanceJarFile(id); + GameInstanceManifest launchManifest = getResolvedManifest().launchManifest(); + GameInstanceID jarId = Optional.ofNullable(launchManifest.jar()).orElse(launchManifest.id()); + return layout.getInstanceJarFile(jarId); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index b89df44681a..ed54c2a0628 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -410,6 +410,16 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { } } + /// Removes an instance from the published index and attempts to remove its backing directory. + /// + /// The repository is refreshed before this method returns, including when filesystem removal + /// fails after the instance has been removed from the published snapshot. After the instance + /// directory is staged under its `_removed` sibling, failure to trash or fully delete that + /// staging directory is logged but does not change the return value. + /// + /// @param id the instance id + /// @return `false` if removal is denied or the instance directory cannot be staged; `true` if + /// the directory is absent or staging succeeds public boolean removeInstanceFromDisk(GameInstanceID id) { if (EventBus.EVENT_BUS.fireEvent(new RemoveInstanceEvent(this, id)) == Event.Result.DENY) { return false; @@ -421,20 +431,20 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { publishSnapshot(newSnapshot); } - Path file = getLayout().getInstanceRoot(id); - if (Files.notExists(file)) { - return true; - } - - Path removedFile = file.toAbsolutePath().resolveSibling(FileUtils.getName(file) + "_removed"); try { - Files.move(file, removedFile, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - LOG.warning("Unable to remove version folder: " + file, e); - return false; - } + Path file = getLayout().getInstanceRoot(id); + if (Files.notExists(file)) { + return true; + } + + Path removedFile = file.toAbsolutePath().resolveSibling(FileUtils.getName(file) + "_removed"); + try { + Files.move(file, removedFile, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + LOG.warning("Unable to remove version folder: " + file, e); + return false; + } - try { if (FileUtils.moveToTrash(removedFile)) { return true; } @@ -454,7 +464,7 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { } return true; } finally { - refreshAsync().start(); + refresh(); } } @@ -470,7 +480,7 @@ public Optional getGameVersion(GameInstanceID instanceId) throws NoSuchG @Override public Optional getGameVersion(GameInstanceManifest manifest) { DefaultGameInstance instance = findSnapshotInstance(manifest.id()); - if (instance != null && !instance.isProvisional()) { + if (instance != null && !instance.isProvisional() && manifest.equals(instance.getManifest())) { GameVersionNumber version = instance.getVersion(); if (version == GameVersionNumber.unknown()) { return Optional.empty(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java index 014447f9369..c4c9c4bb02e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java @@ -20,32 +20,56 @@ import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.task.Task; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; +/// Provides format-specific operations for reading, installing, updating, and completing modpacks. +@NotNullByDefault public interface ModpackProvider { + /// Returns the persistent provider name stored in modpack configurations. + /// + /// @return the provider name String getName(); - Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId); + /// Creates a task that completes missing or outdated files for a registered instance. + /// + /// @param dependencyManager the dependency manager for `instance`'s repository + /// @param instance the registered instance to complete + /// @return the completion task, or `null` when this format requires no completion + @Nullable Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance); - Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException; + /// Creates a task that updates a registered instance from a local modpack archive. + /// + /// @param dependencyManager the dependency manager for `instance`'s repository + /// @param instance the registered instance to update + /// @param zipFile the modpack archive + /// @param modpack the parsed modpack + /// @return the update task + /// @throws MismatchedModpackTypeException if the parsed manifest belongs to another provider + Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException; - /** - * @param zipFile the opened modpack zip file. - * @param file the modpack zip file path. - * @param encoding encoding of zip file. - * @throws IOException if the file is not a valid zip file. - * @throws JsonParseException if the manifest.json is missing or malformed. - * @return the manifest. - */ + /// Reads this provider's manifest from an opened modpack archive. + /// + /// @param zipFile the opened modpack archive + /// @param file the modpack archive path + /// @param encoding the archive entry-name encoding + /// @return the parsed modpack + /// @throws IOException if the archive cannot be read as this format + /// @throws JsonParseException if the required manifest is missing or malformed Modpack readManifest(ZipArchiveReader zipFile, Path file, Charset encoding) throws IOException, JsonParseException; + /// Injects provider-specific launch options from a serialized modpack configuration. + /// + /// @param modpackConfigurationJson the serialized configuration + /// @param builder the launch options builder to update default void injectLaunchOptions(String modpackConfigurationJson, LaunchOptions.Builder builder) { } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java index f266348cfc0..7638a7697a3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java @@ -17,64 +17,94 @@ */ package org.jackhuang.hmcl.modpack; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collection; import java.util.Collections; +/// Runs a modpack update with an instance-directory backup and rollback on failure. +@NotNullByDefault public class ModpackUpdateTask extends Task { + /// The fixed pre-update instance snapshot. + private final DefaultGameInstance instance; + + /// The repository that owns [#instance]. private final DefaultGameRepository repository; + + /// The ID of [#instance]. private final GameInstanceID id; + + /// The task that applies the modpack update after the backup is created. private final Task updateTask; + + /// A randomly named backup directory that was unused when this task was created. private final Path backupFolder; - public ModpackUpdateTask(DefaultGameRepository repository, GameInstanceID instanceId, Task updateTask) { - this.repository = repository; - this.id = instanceId; + /// Creates an update task that backs up and restores a registered instance as one operation. + /// + /// @param instance the registered instance to update + /// @param updateTask the task that performs the update + public ModpackUpdateTask(DefaultGameInstance instance, Task updateTask) { + this.instance = instance; + this.repository = instance.getRepository(); + this.id = instance.getId(); this.updateTask = updateTask; - Path backup = repository.getBaseDirectory().resolve("backup"); + Path backup = instance.getLayout().getBaseDirectory().resolve("backup"); while (true) { int num = (int)(Math.random() * 10000000); - if (!Files.exists(backup.resolve(instanceId + "-" + num))) { - backupFolder = backup.resolve(instanceId + "-" + num); + if (!Files.exists(backup.resolve(id + "-" + num))) { + backupFolder = backup.resolve(id + "-" + num); break; } } } + /// Returns the update task that runs after this task creates the backup. + /// + /// @return a singleton containing the update task @Override public Collection> getDependencies() { return Collections.singleton(updateTask); } + /// Copies the instance directory into the backup directory. @Override public void execute() throws Exception { - FileUtils.copyDirectory(repository.getLayout().getInstanceRoot(id), backupFolder); + FileUtils.copyDirectory(instance.getInstanceRoot(), backupFolder); } + /// Requests post-execution cleanup or rollback after the update task terminates. + /// + /// @return `true` @Override public boolean doPostExecute() { return true; } + /// Retains the backup after success, or restores it and refreshes the repository after failure. @Override public void postExecute() throws Exception { if (isDependenciesSucceeded()) { // Keep backup game version for further repair. } else { // Restore backup - repository.removeInstanceFromDisk(id); + if (!repository.removeInstanceFromDisk(id)) { + throw new IOException("Failed to remove instance before restoring backup: " + id); + } - FileUtils.copyDirectory(backupFolder, repository.getLayout().getInstanceRoot(id)); + FileUtils.copyDirectory(backupFolder, instance.getInstanceRoot()); - repository.refreshAsync().start(); + repository.refresh(); } } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java index 3dab545053c..75139b7e3c6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java @@ -21,15 +21,16 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.mod.ModManager; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.ModpackCompletionException; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.FileNotFoundException; import java.io.IOException; @@ -44,51 +45,59 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/** - * Complete the CurseForge version. - * - * @author huangyuhui - */ +/// Completes missing files for an installed CurseForge modpack. +@NotNullByDefault public final class CurseCompletionTask extends Task { + /// The dependency manager used to resolve and download remote files. private final DefaultDependencyManager dependency; - private final DefaultGameRepository repository; + + /// The fixed registered instance completed by this task. + private final DefaultGameInstance instance; + + /// The mod manager associated with [#instance]. private final ModManager modManager; - private final GameInstanceID instanceId; - private CurseManifest manifest; - private List> dependencies; + /// The manifest supplied by the caller or loaded from disk, if available. + private @Nullable CurseManifest manifest; + + /// Download tasks produced during [#execute()]. + private List> dependencies = List.of(); + + /// Whether every manifest file name could be resolved. private final AtomicBoolean allNameKnown = new AtomicBoolean(true); + + /// The number of manifest entries processed in the current phase. private final AtomicInteger finished = new AtomicInteger(0); + + /// Whether a manifest entry refers to a deleted remote file. private final AtomicBoolean notFound = new AtomicBoolean(false); - /** - * Constructor. - * - * @param dependencyManager the dependency manager. - * @param instanceId the existent and physical version. - */ - public CurseCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - this(dependencyManager, instanceId, null); + /// Creates a task that completes the installed CurseForge modpack. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + public CurseCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + this(dependencyManager, instance, null); } - /** - * Constructor. - * - * @param dependencyManager the dependency manager. - * @param instanceId the existent and physical version. - * @param manifest the CurseForgeModpack manifest. - */ - public CurseCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, CurseManifest manifest) { + /// Creates a task that completes the installed CurseForge modpack using an optional manifest. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + /// @param manifest the CurseForge manifest, or `null` to read it from disk + public CurseCompletionTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + @Nullable CurseManifest manifest) { this.dependency = dependencyManager; - this.repository = dependencyManager.getGameRepository(); - this.modManager = repository.getModManager(instanceId); - this.instanceId = instanceId; + this.instance = instance; + this.modManager = instance.getModManager(); this.manifest = manifest; if (manifest == null) try { - Path manifestFile = repository.getLayout().getInstanceRoot(instanceId).resolve("manifest.json"); + Path manifestFile = instance.getInstanceRoot().resolve("manifest.json"); if (Files.exists(manifestFile)) this.manifest = JsonUtils.fromJsonFile(manifestFile, CurseManifest.class); } catch (Exception e) { @@ -113,7 +122,7 @@ public void execute() throws Exception { if (manifest == null) return; - Path root = repository.getLayout().getInstanceRoot(instanceId); + Path root = instance.getInstanceRoot(); // Because in China, Curse is too difficult to visit, // if failed, ignore it and retry next time. @@ -141,8 +150,7 @@ public void execute() throws Exception { .collect(Collectors.toList())); JsonUtils.writeToJsonFile(root.resolve("manifest.json"), newManifest); - GameInstanceID instanceId1 = modManager.getInstanceId(); - Path versionRoot = repository.getLayout().getInstanceRoot(instanceId1); + Path versionRoot = instance.getInstanceRoot(); Path resourcePacksRoot = versionRoot.resolve("resourcepacks"); Path shaderPacksRoot = versionRoot.resolve("shaderpacks"); finished.set(0); @@ -175,17 +183,15 @@ public void execute() throws Exception { } } - /** - * Guess where to store the file. - * - * @param file The file. - * @param downloadProvider - * @param resourcePacksRoot ./resourcepacks. - * @param shaderPacksRoot ./shaderpacks. - * @return ./resourcepacks/$filename or ./shaderpacks/$filename or ./mods/$filename if the file doesn't exist. null if the file existed. - * @throws IOException If IOException was encountered during getting data from CurseForge. - */ - private Path guessFilePath(CurseManifestFile file, DownloadProvider downloadProvider, Path resourcePacksRoot, Path shaderPacksRoot) throws IOException { + /// Returns the destination for a missing CurseForge file based on its project class. + /// + /// @param file the manifest file + /// @param downloadProvider the download provider used for CurseForge requests + /// @param resourcePacksRoot the resource-pack directory + /// @param shaderPacksRoot the shader-pack directory + /// @return the destination, or `null` when the file already exists + /// @throws IOException if CurseForge metadata cannot be read + private @Nullable Path guessFilePath(CurseManifestFile file, DownloadProvider downloadProvider, Path resourcePacksRoot, Path shaderPacksRoot) throws IOException { RemoteAddon mod = CurseForgeRemoteAddonRepository.MODS.getAddonById(downloadProvider, Integer.toString(file.projectID())); int classID = ((CurseForgeRemoteAddonRepository.CurseAddon) mod.data()).classId(); String fileName = file.fileName(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java index 484a369a1fa..a069024112b 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 @@ -126,7 +126,6 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile dependents.add(downloadIconTask = new CacheFileTask(dependencyManager.getDownloadProvider().injectURLWithCandidates(iconUrl))); } } - dependencies.add(new CurseCompletionTask(dependencyManager, instanceId, manifest)); } @Override @@ -208,5 +207,8 @@ public void execute() throws Exception { LOG.warning("Failed to copy modpack icon", e); } } + + // The game builder runs as a dependent and registers the instance before this phase. + dependencies.add(new CurseCompletionTask(dependencyManager, repository.getInstance(instanceId), manifest)); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java index f66aef4f19a..9cc2fc59aa3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java @@ -21,6 +21,7 @@ import kala.compress.archivers.zip.ZipArchiveEntry; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; import org.jackhuang.hmcl.modpack.Modpack; @@ -44,16 +45,16 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - return new CurseCompletionTask(dependencyManager, instanceId); + public Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + return new CurseCompletionTask(dependencyManager, instance); } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof CurseManifest curseManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new CurseInstallTask(dependencyManager, zipFile, modpack, curseManifest, instanceId, null)); + return new ModpackUpdateTask(instance, new CurseInstallTask(dependencyManager, zipFile, modpack, curseManifest, instance.getId(), null)); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java index 78906b4de16..9fd1fefae96 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java @@ -19,9 +19,8 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.mod.ModManager; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.modpack.ModpackCompletionException; import org.jackhuang.hmcl.modpack.curse.CurseMetaMod; @@ -31,6 +30,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.FileNotFoundException; @@ -49,31 +49,49 @@ import static org.jackhuang.hmcl.util.Lang.wrapConsumer; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Completes and updates files for an installed MCBBS modpack. +@NotNullByDefault public class McbbsModpackCompletionTask extends CompletableFutureTask { + /// The dependency manager used to resolve and download remote files. private final DefaultDependencyManager dependency; - private final DefaultGameRepository repository; + + /// The fixed registered instance completed by this task. + private final DefaultGameInstance instance; + + /// The mod manager associated with [#instance]. private final ModManager modManager; - private final GameInstanceID instanceId; + + /// The fixed configuration-file path for [#instance]. private final Path configurationFile; - private ModpackConfiguration configuration; - private McbbsModpackManifest manifest; - private final List> dependencies = new ArrayList<>(); - private final AtomicBoolean allNameKnown = new AtomicBoolean(true); - private final AtomicInteger finished = new AtomicInteger(0); - private final AtomicBoolean notFound = new AtomicBoolean(false); + /// The configuration supplied by the caller or loaded from disk. + private @Nullable ModpackConfiguration configuration; + + /// The local or downloaded manifest currently being processed. + private @Nullable McbbsModpackManifest manifest; - public McbbsModpackCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - this(dependencyManager, instanceId, null); + /// Creates a task that loads the modpack configuration from disk. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + public McbbsModpackCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + this(dependencyManager, instance, null); } - public McbbsModpackCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, ModpackConfiguration configuration) { + /// Creates a task using an optional preloaded modpack configuration. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + /// @param configuration the configuration, or `null` to read it from disk + public McbbsModpackCompletionTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + @Nullable ModpackConfiguration configuration) { this.dependency = dependencyManager; - this.repository = dependencyManager.getGameRepository(); - this.modManager = repository.getModManager(instanceId); - this.instanceId = instanceId; - this.configurationFile = repository.getModpackConfiguration(instanceId); + this.instance = instance; + this.modManager = instance.getModManager(); + this.configurationFile = instance.getRepository().getModpackConfiguration(instance.getId()); this.configuration = configuration; setStage("hmcl.modpack.download"); @@ -110,7 +128,7 @@ public CompletableFuture getFuture(TaskCompletableFuture executor) { throw new IOException("Unable to parse server manifest.json from " + manifest.getFileApi(), e); } - Path rootPath = repository.getLayout().getInstanceRoot(instanceId); + Path rootPath = instance.getInstanceRoot(); Files.createDirectories(rootPath); Map localFiles = manifest.getFiles().stream().collect(Collectors.toMap(Function.identity(), Function.identity())); @@ -172,8 +190,7 @@ public CompletableFuture getFuture(TaskCompletableFuture executor) { manifest = remoteManifest.setFiles(newFiles); return executor.all(tasks.stream().filter(Objects::nonNull).collect(Collectors.toList())); })).thenAcceptAsync(wrapConsumer(unused1 -> { - Path manifestFile = repository.getModpackConfiguration(instanceId); - JsonUtils.writeToJsonFile(manifestFile, + JsonUtils.writeToJsonFile(configurationFile, new ModpackConfiguration<>(manifest, this.configuration.getType(), this.manifest.getName(), this.manifest.getVersion(), this.manifest.getFiles().stream() .flatMap(file -> file instanceof McbbsModpackManifest.AddonFile @@ -271,10 +288,9 @@ public CompletableFuture getFuture(TaskCompletableFuture executor) { })); } - @Nullable - private Path getFilePath(McbbsModpackManifest.File file) { + private @Nullable Path getFilePath(McbbsModpackManifest.File file) { if (file instanceof McbbsModpackManifest.AddonFile) { - return modManager.getRepository().getRunDirectory(modManager.getInstanceId()).resolve(((McbbsModpackManifest.AddonFile) file).getPath()); + return instance.getRunDirectory().resolve(((McbbsModpackManifest.AddonFile) file).getPath()); } else if (file instanceof McbbsModpackManifest.CurseFile) { String fileName = ((McbbsModpackManifest.CurseFile) file).getFileName(); if (fileName == null) return null; @@ -284,7 +300,7 @@ private Path getFilePath(McbbsModpackManifest.File file) { } } - private String getFileHash(McbbsModpackManifest.File file) { + private @Nullable String getFileHash(McbbsModpackManifest.File file) { if (file instanceof McbbsModpackManifest.AddonFile) { return ((McbbsModpackManifest.AddonFile) file).getHash(); } else { @@ -292,7 +308,7 @@ private String getFileHash(McbbsModpackManifest.File file) { } } - private Task downloadFile(McbbsModpackManifest remoteManifest, McbbsModpackManifest.File file) throws IOException { + private @Nullable Task downloadFile(McbbsModpackManifest remoteManifest, McbbsModpackManifest.File file) throws IOException { if (file instanceof McbbsModpackManifest.AddonFile) { McbbsModpackManifest.AddonFile addonFile = (McbbsModpackManifest.AddonFile) file; return new FileDownloadTask( diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java index bdbc6832629..381249134b7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java @@ -18,8 +18,7 @@ package org.jackhuang.hmcl.modpack.mcbbs; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.Library; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; @@ -32,6 +31,8 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.Zipper; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; import java.io.File; import java.io.IOException; @@ -44,15 +45,25 @@ import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Exports one registered game instance as an MCBBS modpack archive. +@NotNullByDefault public class McbbsModpackExportTask extends Task { - private final DefaultGameRepository repository; - private final GameInstanceID instanceId; + /// The fixed instance snapshot exported by this task. + private final DefaultGameInstance instance; + + /// The validated export configuration. private final ModpackExportInfo info; + + /// The archive written by this task. private final Path modpackFile; - public McbbsModpackExportTask(DefaultGameRepository repository, GameInstanceID instanceId, ModpackExportInfo info, Path modpackFile) { - this.repository = repository; - this.instanceId = instanceId; + /// Creates an MCBBS modpack export task. + /// + /// @param instance the registered instance snapshot to export + /// @param info the export configuration + /// @param modpackFile the archive to write + public McbbsModpackExportTask(DefaultGameInstance instance, ModpackExportInfo info, Path modpackFile) { + this.instance = instance; this.info = info.validate(); this.modpackFile = modpackFile; @@ -67,14 +78,16 @@ public McbbsModpackExportTask(DefaultGameRepository repository, GameInstanceID i }); } + /// {@inheritDoc} @Override public void execute() throws Exception { + var instanceId = instance.getId(); ArrayList blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(instanceId + ".jar"); blackList.add(instanceId + ".json"); LOG.info("Compressing game files without some files in blacklist, including files or directories: usernamecache.json, asm, logs, backups, versions, assets, usercache.json, libraries, crash-reports, launcher_profiles.json, NVIDIA, TCNodeTracker"); try (var zip = new Zipper(modpackFile)) { - Path runDirectory = repository.getRunDirectory(instanceId); + Path runDirectory = instance.getRunDirectory(); List files = new ArrayList<>(); zip.putDirectory(runDirectory, "overrides", path -> { if (Modpack.acceptFile(path, blackList, info.getWhitelist())) { @@ -89,9 +102,12 @@ public void execute() throws Exception { } }); - String gameVersion = repository.getGameVersion(instanceId) - .orElseThrow(() -> new IOException("Cannot parse the version of " + instanceId)); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + throw new IOException("Cannot parse the version of " + instanceId); + } + String gameVersion = version.toString(); + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); // Mcbbs manifest List addons = new ArrayList<>(); @@ -136,6 +152,7 @@ public void execute() throws Exception { } } + /// Export options supported by the MCBBS format. public static final ModpackExportInfo.Options OPTION = new ModpackExportInfo.Options() .requireFileApi(true) .requireUrl() diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java index 110a1a82d37..86fcaa0b4ba 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 @@ -119,7 +119,10 @@ public void execute() throws Exception { // TODO: maintain libraries. } - dependencies.add(new McbbsModpackCompletionTask(dependencyManager, instanceId, instanceTask.getResult())); + dependencies.add(new McbbsModpackCompletionTask( + dependencyManager, + repository.getInstance(instanceId), + instanceTask.getResult())); } private static final String PATCH_NAME = "mcbbs"; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java index 5c63d3d0649..d4993662b46 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java @@ -21,7 +21,7 @@ import kala.compress.archivers.zip.ZipArchiveEntry; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.modpack.*; import org.jackhuang.hmcl.task.Task; @@ -41,16 +41,16 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - return new McbbsModpackCompletionTask(dependencyManager, instanceId); + public Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + return new McbbsModpackCompletionTask(dependencyManager, instance); } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof McbbsModpackManifest mcbbsModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new McbbsModpackLocalInstallTask(dependencyManager, zipFile, modpack, mcbbsModpackManifest, instanceId)); + return new ModpackUpdateTask(instance, new McbbsModpackLocalInstallTask(dependencyManager, zipFile, modpack, mcbbsModpackManifest, instance.getId())); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java index c8e0b80c05f..d7b54e1d2df 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java @@ -87,7 +87,10 @@ public List> getDependencies() { @Override public void execute() throws Exception { - dependencies.add(new McbbsModpackCompletionTask(dependency, instanceId, new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))); + dependencies.add(new McbbsModpackCompletionTask( + dependency, + repository.getInstance(instanceId), + new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))); } public static final String MODPACK_TYPE = "Server"; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java index 4dc5022c64c..ca894ed63a2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java @@ -18,14 +18,15 @@ package org.jackhuang.hmcl.modpack.modrinth; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.mod.ModManager; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.ModpackCompletionException; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.FileNotFoundException; import java.io.IOException; @@ -39,46 +40,59 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Completes missing files for an installed Modrinth modpack. +@NotNullByDefault public class ModrinthCompletionTask extends Task { + /// The dependency manager used to download remote files. private final DefaultDependencyManager dependency; - private final DefaultGameRepository repository; + + /// The fixed registered instance completed by this task. + private final DefaultGameInstance instance; + + /// The mod manager associated with [#instance]. private final ModManager modManager; - private final GameInstanceID instanceId; - private ModrinthManifest manifest; + + /// The manifest supplied by the caller or loaded from disk, if available. + private @Nullable ModrinthManifest manifest; + + /// Download tasks produced during [#execute()]. private final List> dependencies = new ArrayList<>(); + /// Whether every required download has at least one usable URL. private final AtomicBoolean allNameKnown = new AtomicBoolean(true); + + /// The number of manifest entries processed. private final AtomicInteger finished = new AtomicInteger(0); + + /// Whether a required file has no usable download URL. private final AtomicBoolean notFound = new AtomicBoolean(false); - /** - * Constructor. - * - * @param dependencyManager the dependency manager. - * @param instanceId the existent and physical version. - */ - public ModrinthCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - this(dependencyManager, instanceId, null); + /// Creates a task that completes the installed Modrinth modpack. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + public ModrinthCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + this(dependencyManager, instance, null); } - /** - * Constructor. - * - * @param dependencyManager the dependency manager. - * @param instanceId the existent and physical version. - * @param manifest the CurseForgeModpack manifest. - */ - public ModrinthCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, ModrinthManifest manifest) { + /// Creates a task that completes the installed Modrinth modpack using an optional manifest. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + /// @param manifest the Modrinth manifest, or `null` to read it from disk + public ModrinthCompletionTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + @Nullable ModrinthManifest manifest) { this.dependency = dependencyManager; - this.repository = dependencyManager.getGameRepository(); - this.modManager = repository.getModManager(instanceId); - this.instanceId = instanceId; + this.instance = instance; + this.modManager = instance.getModManager(); this.manifest = manifest; if (manifest == null) try { - Path manifestFile = repository.getLayout().getInstanceRoot(instanceId).resolve("modrinth.index.json"); + Path manifestFile = instance.getInstanceRoot().resolve("modrinth.index.json"); if (Files.exists(manifestFile)) this.manifest = JsonUtils.fromJsonFile(manifestFile, ModrinthManifest.class); } catch (Exception e) { @@ -103,7 +117,7 @@ public void execute() throws Exception { if (manifest == null) return; - Path runDirectory = FileUtils.toAbsolute(repository.getRunDirectory(instanceId)); + Path runDirectory = FileUtils.toAbsolute(instance.getRunDirectory()); Path modsDirectory = runDirectory.resolve("mods"); for (ModrinthManifest.File file : manifest.getFiles()) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java index aab7a246166..2e77f682498 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 @@ -127,7 +127,6 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF dependents.add(downloadIconTask = new CacheFileTask(dependencyManager.getDownloadProvider().injectURLWithCandidates(iconUrl))); } } - dependencies.add(new ModrinthCompletionTask(dependencyManager, instanceId, manifest)); } @Override @@ -164,5 +163,8 @@ public void execute() throws Exception { LOG.warning("Failed to copy modpack icon", e); } } + + // The game builder runs as a dependent and registers the instance before this phase. + dependencies.add(new ModrinthCompletionTask(dependencyManager, repository.getInstance(instanceId), manifest)); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java index 25edbf60a0f..1bb920e15a3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java @@ -24,10 +24,10 @@ import java.nio.file.Paths; import java.util.*; +import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackExportInfo; @@ -35,22 +35,38 @@ import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.Zipper; -import org.jackhuang.hmcl.addon.mod.LocalModFile; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Exports one registered game instance as a Modrinth modpack archive. +@NotNullByDefault public class ModrinthModpackExportTask extends Task { - private final DefaultGameRepository repository; - private final GameInstanceID instanceId; + /// The fixed instance snapshot exported by this task. + private final DefaultGameInstance instance; + + /// The mod manager associated with the exported instance. + private final ModManager modManager; + + /// The validated export configuration. private final ModpackExportInfo info; + + /// The archive written by this task. private final Path modpackFile; - public ModrinthModpackExportTask(DefaultGameRepository repository, GameInstanceID instanceId, ModpackExportInfo info, Path modpackFile) { - this.repository = repository; - this.instanceId = instanceId; + /// Creates a Modrinth modpack export task. + /// + /// @param instance the registered instance snapshot to export + /// @param info the export configuration + /// @param modpackFile the archive to write + public ModrinthModpackExportTask(DefaultGameInstance instance, ModpackExportInfo info, Path modpackFile) { + this.instance = instance; + this.modManager = instance.getModManager(); this.info = info.validate(); this.modpackFile = modpackFile; @@ -65,17 +81,21 @@ public ModrinthModpackExportTask(DefaultGameRepository repository, GameInstanceI }); } - private ModrinthManifest.File tryGetRemoteFile(Path file, String relativePath) throws IOException { + /// Returns a remote-file manifest entry for a local file when one can be identified. + /// + /// @param file the local file + /// @param relativePath the archive-relative path + /// @return the remote-file entry, or `null` when the file must be included in overrides + private @Nullable ModrinthManifest.File tryGetRemoteFile(Path file, String relativePath) throws IOException { if (info.isNoCreateRemoteFiles()) { return null; } - boolean isDisabled = repository.getModManager(instanceId).isDisabled(file); + boolean isDisabled = modManager.isDisabled(file); if (isDisabled) { - relativePath = repository.getModManager(instanceId).enableMod(Paths.get(relativePath)).toString(); + relativePath = modManager.enableMod(Paths.get(relativePath)).toString(); } - LocalModFile localModFile = null; Optional modrinthVersion = Optional.empty(); Optional curseForgeVersion = Optional.empty(); @@ -101,7 +121,7 @@ private ModrinthManifest.File tryGetRemoteFile(Path file, String relativePath) t hashes.put("sha1", DigestUtils.digestToString("SHA-1", file)); hashes.put("sha512", DigestUtils.digestToString("SHA-512", file)); - Map env = null; + @Nullable Map env = null; if (isDisabled) { env = new HashMap<>(); env.put("client", "optional"); @@ -126,14 +146,16 @@ private ModrinthManifest.File tryGetRemoteFile(Path file, String relativePath) t ); } + /// {@inheritDoc} @Override public void execute() throws Exception { + var instanceId = instance.getId(); ArrayList blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(instanceId + ".jar"); blackList.add(instanceId + ".json"); LOG.info("Compressing game files without some files in blacklist, including files or directories: usernamecache.json, asm, logs, backups, versions, assets, usercache.json, libraries, crash-reports, launcher_profiles.json, NVIDIA, TCNodeTracker"); try (var zip = new Zipper(modpackFile)) { - Path runDirectory = repository.getRunDirectory(instanceId); + Path runDirectory = instance.getRunDirectory(); List files = new ArrayList<>(); Set filesInManifest = new HashSet<>(); @@ -171,9 +193,12 @@ public void execute() throws Exception { return Modpack.acceptFile(path, blackList, info.getWhitelist()); }); - String gameVersion = repository.getGameVersion(instanceId) - .orElseThrow(() -> new IOException("Cannot parse the version of " + instanceId)); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + throw new IOException("Cannot parse the version of " + instanceId); + } + String gameVersion = version.toString(); + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); Map dependencies = new HashMap<>(); dependencies.put("minecraft", gameVersion); @@ -201,6 +226,7 @@ public void execute() throws Exception { } } + /// Export options supported by the Modrinth format. public static final ModpackExportInfo.Options OPTION = new ModpackExportInfo.Options() .requireNoCreateRemoteFiles() .requireSkipCurseForgeRemoteFiles(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java index 6b4f7374bf5..f1eb340bbae 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java @@ -20,6 +20,7 @@ import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; import org.jackhuang.hmcl.modpack.Modpack; @@ -42,16 +43,16 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - return new ModrinthCompletionTask(dependencyManager, instanceId); + public Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + return new ModrinthCompletionTask(dependencyManager, instance); } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof ModrinthManifest modrinthManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new ModrinthInstallTask(dependencyManager, zipFile, modpack, modrinthManifest, instanceId, null)); + return new ModpackUpdateTask(instance, new ModrinthInstallTask(dependencyManager, zipFile, modpack, modrinthManifest, instance.getId(), null)); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java index a27f9ca961a..91692d278f3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java @@ -18,14 +18,15 @@ package org.jackhuang.hmcl.modpack.multimc; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackExportInfo; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.Zipper; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; import java.io.IOException; import java.io.StringWriter; @@ -38,23 +39,29 @@ import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/** - * Export the game to a mod pack file. - */ +/// Exports one registered game instance as a MultiMC modpack archive. +@NotNullByDefault public class MultiMCModpackExportTask extends Task { - private final DefaultGameRepository repository; - private final GameInstanceID instanceId; + /// The fixed instance snapshot exported by this task. + private final DefaultGameInstance instance; + + /// The paths selected for inclusion in the archive. private final List whitelist; + + /// The MultiMC instance configuration written to the archive. private final MultiMCInstanceConfiguration configuration; + + /// The archive written by this task. private final Path output; - /** - * @param output mod pack file. - * @param instanceId to locate version.json - */ - public MultiMCModpackExportTask(DefaultGameRepository repository, GameInstanceID instanceId, List whitelist, MultiMCInstanceConfiguration configuration, Path output) { - this.repository = repository; - this.instanceId = instanceId; + /// Creates a MultiMC modpack export task. + /// + /// @param instance the registered instance snapshot to export + /// @param whitelist the paths selected for inclusion + /// @param configuration the MultiMC instance configuration + /// @param output the archive to write + public MultiMCModpackExportTask(DefaultGameInstance instance, List whitelist, MultiMCInstanceConfiguration configuration, Path output) { + this.instance = instance; this.whitelist = whitelist; this.configuration = configuration; this.output = output; @@ -70,18 +77,23 @@ public MultiMCModpackExportTask(DefaultGameRepository repository, GameInstanceID }); } + /// {@inheritDoc} @Override public void execute() throws Exception { + var instanceId = instance.getId(); ArrayList blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(instanceId + ".jar"); blackList.add(instanceId + ".json"); LOG.info("Compressing game files without some files in blacklist, including files or directories: usernamecache.json, asm, logs, backups, versions, assets, usercache.json, libraries, crash-reports, launcher_profiles.json, NVIDIA, TCNodeTracker"); try (Zipper zip = new Zipper(output)) { - zip.putDirectory(repository.getRunDirectory(instanceId), ".minecraft", path -> Modpack.acceptFile(path, blackList, whitelist)); + zip.putDirectory(instance.getRunDirectory(), ".minecraft", path -> Modpack.acceptFile(path, blackList, whitelist)); - String gameVersion = repository.getGameVersion(instanceId) - .orElseThrow(() -> new IOException("Cannot parse the version of " + instanceId)); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + throw new IOException("Cannot parse the version of " + instanceId); + } + String gameVersion = version.toString(); + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); List components = new ArrayList<>(); components.add(new MultiMCManifest.MultiMCManifestComponent(true, false, MultiMCComponents.getComponent(MINECRAFT), gameVersion)); @@ -104,6 +116,7 @@ public void execute() throws Exception { } } + /// Export options supported by the MultiMC format. public static final ModpackExportInfo.Options OPTION = new ModpackExportInfo.Options() .requireAuthor() .requireMinMemory(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java index 479095d981d..966dd0ef372 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java @@ -20,6 +20,7 @@ import kala.compress.archivers.zip.ZipArchiveEntry; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; import org.jackhuang.hmcl.modpack.Modpack; @@ -27,6 +28,7 @@ import org.jackhuang.hmcl.modpack.ModpackUpdateTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -42,16 +44,16 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { + public @Nullable Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { return null; } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof MultiMCInstanceConfiguration multiMCInstanceConfiguration)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new MultiMCModpackInstallTask(dependencyManager, zipFile, modpack, multiMCInstanceConfiguration, instanceId)); + return new ModpackUpdateTask(instance, new MultiMCModpackInstallTask(dependencyManager, zipFile, modpack, multiMCInstanceConfiguration, instance.getId())); } private static String getRootEntryName(ZipArchiveReader file) throws IOException { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java index f895c7b6ba3..c24e0146108 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java @@ -20,9 +20,8 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; -import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.LocalAddonManager; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.GetTask; @@ -30,6 +29,8 @@ import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -40,30 +41,56 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Synchronizes an installed server modpack with its remote manifest. +@NotNullByDefault public class ServerModpackCompletionTask extends Task { + /// The dependency manager used for downloads and game-component updates. private final DefaultDependencyManager dependencyManager; - private final DefaultGameRepository repository; - private final GameInstanceID instanceId; - private ModpackConfiguration manifest; - private GetTask dependent; - private ServerModpackManifest remoteManifest; + + /// The fixed registered instance completed by this task. + private final DefaultGameInstance instance; + + /// The fixed configuration-file path for [#instance]. + private final Path configurationFile; + + /// The installed configuration supplied by the caller or loaded from disk. + private @Nullable ModpackConfiguration manifest; + + /// The remote-manifest request created during [#preExecute()]. + private @Nullable GetTask dependent; + + /// The remote manifest parsed during [#execute()]. + private @Nullable ServerModpackManifest remoteManifest; + + /// Download and game-builder tasks produced during [#execute()]. private final List> dependencies = new ArrayList<>(); - public ServerModpackCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - this(dependencyManager, instanceId, null); + /// Creates a task that loads the installed configuration from disk. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + public ServerModpackCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + this(dependencyManager, instance, null); } - public ServerModpackCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, ModpackConfiguration manifest) { + /// Creates a task using an optional preloaded configuration. + /// + /// @param dependencyManager the dependency manager + /// @param instance the registered instance to complete + /// @param manifest the installed configuration, or `null` to read it from disk + public ServerModpackCompletionTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + @Nullable ModpackConfiguration manifest) { this.dependencyManager = dependencyManager; - this.repository = dependencyManager.getGameRepository(); - this.instanceId = instanceId; + this.instance = instance; + this.configurationFile = instance.getRepository().getModpackConfiguration(instance.getId()); if (manifest == null) { try { - Path manifestFile = repository.getModpackConfiguration(instanceId); - if (Files.exists(manifestFile)) { - this.manifest = JsonUtils.fromJsonFile(manifestFile, ModpackConfiguration.typeOf(ServerModpackManifest.class)); + if (Files.exists(configurationFile)) { + this.manifest = JsonUtils.fromJsonFile(configurationFile, ModpackConfiguration.typeOf(ServerModpackManifest.class)); } } catch (Exception e) { LOG.warning("Unable to read Server modpack manifest.json", e); @@ -113,7 +140,7 @@ public void execute() throws Exception { Map oldAddons = toMap(manifest.getManifest().getAddons()); Map newAddons = toMap(remoteManifest.getAddons()); if (!Objects.equals(oldAddons, newAddons)) { - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId); + GameBuilder builder = dependencyManager.newGameBuilder().name(instance.getId()); for (ServerModpackManifest.Addon addon : remoteManifest.getAddons()) { builder.version(addon.getId(), addon.getVersion()); } @@ -121,7 +148,7 @@ public void execute() throws Exception { dependencies.add(builder.buildAsync()); } - Path rootPath = repository.getLayout().getInstanceRoot(instanceId).toAbsolutePath().normalize(); + Path rootPath = instance.getInstanceRoot().toAbsolutePath().normalize(); Map files = manifest.getManifest().getFiles().stream() .collect(Collectors.toMap(ModpackConfiguration.FileInformation::getPath, Function.identity())); @@ -129,7 +156,7 @@ public void execute() throws Exception { Set remoteFiles = remoteManifest.getFiles().stream().map(ModpackConfiguration.FileInformation::getPath) .collect(Collectors.toSet()); - Path runDirectory = repository.getRunDirectory(instanceId).toAbsolutePath().normalize(); + Path runDirectory = instance.getRunDirectory().toAbsolutePath().normalize(); Path modsDirectory = runDirectory.resolve("mods"); int total = 0; @@ -193,8 +220,7 @@ public boolean doPostExecute() { @Override public void postExecute() throws Exception { if (manifest == null || StringUtils.isBlank(manifest.getManifest().getFileApi())) return; - Path manifestFile = repository.getModpackConfiguration(instanceId); - Files.createDirectories(manifestFile.getParent()); - JsonUtils.writeToJsonFile(manifestFile, new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), this.manifest.getVersion(), remoteManifest.getFiles())); + Files.createDirectories(configurationFile.getParent()); + JsonUtils.writeToJsonFile(configurationFile, new ModpackConfiguration<>(remoteManifest, this.manifest.getType(), this.manifest.getName(), this.manifest.getVersion(), remoteManifest.getFiles())); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java index c2bcfcbe702..3e7e610cf76 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java @@ -18,8 +18,7 @@ package org.jackhuang.hmcl.modpack.server; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; @@ -29,6 +28,8 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.Zipper; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; import java.io.File; import java.io.IOException; @@ -40,15 +41,25 @@ import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Exports one registered game instance as an HMCL server modpack archive. +@NotNullByDefault public class ServerModpackExportTask extends Task { - private final DefaultGameRepository repository; - private final GameInstanceID instanceId; + /// The fixed instance snapshot exported by this task. + private final DefaultGameInstance instance; + + /// The validated export configuration. private final ModpackExportInfo exportInfo; + + /// The archive written by this task. private final Path modpackFile; - public ServerModpackExportTask(DefaultGameRepository repository, GameInstanceID instanceId, ModpackExportInfo exportInfo, Path modpackFile) { - this.repository = repository; - this.instanceId = instanceId; + /// Creates a server modpack export task. + /// + /// @param instance the registered instance snapshot to export + /// @param exportInfo the export configuration + /// @param modpackFile the archive to write + public ServerModpackExportTask(DefaultGameInstance instance, ModpackExportInfo exportInfo, Path modpackFile) { + this.instance = instance; this.exportInfo = exportInfo.validate(); this.modpackFile = modpackFile; @@ -63,14 +74,16 @@ public ServerModpackExportTask(DefaultGameRepository repository, GameInstanceID }); } + /// {@inheritDoc} @Override public void execute() throws Exception { + var instanceId = instance.getId(); ArrayList blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(instanceId + ".jar"); blackList.add(instanceId + ".json"); LOG.info("Compressing game files without some files in blacklist, including files or directories: usernamecache.json, asm, logs, backups, versions, assets, usercache.json, libraries, crash-reports, launcher_profiles.json, NVIDIA, TCNodeTracker"); try (Zipper zip = new Zipper(modpackFile)) { - Path runDirectory = repository.getRunDirectory(instanceId); + Path runDirectory = instance.getRunDirectory(); List files = new ArrayList<>(); zip.putDirectory(runDirectory, "overrides", path -> { if (Modpack.acceptFile(path, blackList, exportInfo.getWhitelist())) { @@ -85,9 +98,12 @@ public void execute() throws Exception { } }); - String gameVersion = repository.getGameVersion(instanceId) - .orElseThrow(() -> new IOException("Cannot parse the version of " + instanceId)); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(repository.getResolvedInstanceManifest(instanceId), gameVersion); + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + throw new IOException("Cannot parse the version of " + instanceId); + } + String gameVersion = version.toString(); + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); List addons = new ArrayList<>(); addons.add(new ServerModpackManifest.Addon(MINECRAFT.getPatchId(), gameVersion)); analyzer.getVersion(FORGE).ifPresent(forgeVersion -> @@ -107,6 +123,7 @@ public void execute() throws Exception { } } + /// Export options supported by the server modpack format. public static final ModpackExportInfo.Options OPTION = new ModpackExportInfo.Options() .requireAuthor() .requireFileApi(false); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java index 76e98f7fddf..90e82f03924 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java @@ -20,7 +20,7 @@ import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackProvider; @@ -42,16 +42,16 @@ public String getName() { } @Override - public Task createCompletionTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId) { - return new ServerModpackCompletionTask(dependencyManager, instanceId); + public Task createCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { + return new ServerModpackCompletionTask(dependencyManager, instance); } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, GameInstanceID instanceId, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof ServerModpackManifest serverModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(dependencyManager.getGameRepository(), instanceId, new ServerModpackLocalInstallTask(dependencyManager, zipFile, modpack, serverModpackManifest, instanceId)); + return new ModpackUpdateTask(instance, new ServerModpackLocalInstallTask(dependencyManager, zipFile, modpack, serverModpackManifest, instance.getId())); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java index e29f8438691..9ceb7229260 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 @@ -87,7 +87,10 @@ public List> getDependencies() { @Override public void execute() throws Exception { - dependencies.add(new ServerModpackCompletionTask(dependency, instanceId, new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))); + dependencies.add(new ServerModpackCompletionTask( + dependency, + repository.getInstance(instanceId), + new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))); } public static final String MODPACK_TYPE = "Server"; diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java new file mode 100644 index 00000000000..c77a8663cd7 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -0,0 +1,213 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +/// Tests snapshot-bound behavior of [DefaultGameInstance]. +@NotNullByDefault +public final class DefaultGameInstanceTest { + + /// The selected primary jar follows the resolved manifest's `jar` field. + @Test + public void testPrimaryJarUsesResolvedJarField(@TempDir Path tempDirectory) { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceID jarId = new GameInstanceID("shared-jar"); + TestGameInstance instance = repository.publish(instanceId, + new GameInstanceManifest(instanceId).withJar(jarId)); + + assertEquals(repository.getLayout().getInstanceJarFile(jarId), instance.getInstanceJarFile()); + } + + /// Manifest changes do not reuse version or manager caches from the previous snapshot member. + @Test + public void testManifestChangeInvalidatesDerivedState(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceID oldJarId = new GameInstanceID("old-jar"); + GameInstanceID newJarId = new GameInstanceID("new-jar"); + writeVersionJar(repository.getLayout().getInstanceJarFile(oldJarId), "1.20.1"); + writeVersionJar(repository.getLayout().getInstanceJarFile(newJarId), "1.21.1"); + + GameInstanceManifest oldManifest = new GameInstanceManifest(instanceId).withJar(oldJarId); + TestGameInstance original = repository.publish(instanceId, oldManifest); + assertEquals(GameVersionNumber.asGameVersion("1.20.1"), original.getVersion()); + var originalModManager = original.getModManager(); + var originalResourcePackManager = original.getResourcePackManager(); + + TestGameInstance unchanged = original.withNewSnapshot(repository.newSnapshot()); + assertSame(original.cachedVersion(), unchanged.cachedVersion()); + assertSame(originalModManager, unchanged.getModManager()); + assertSame(originalResourcePackManager, unchanged.getResourcePackManager()); + + GameInstanceManifest newManifest = oldManifest.withJar(newJarId); + TestGameInstance updated = original.withManifest(repository.newSnapshot(), newManifest); + assertNull(updated.cachedVersion()); + assertNotSame(originalModManager, updated.getModManager()); + assertNotSame(originalResourcePackManager, updated.getResourcePackManager()); + assertEquals(GameVersionNumber.asGameVersion("1.21.1"), updated.getVersion()); + } + + /// Version lookup for an explicit manifest does not reuse a same-id instance with different content. + @Test + public void testExplicitManifestDoesNotReuseDifferentCachedManifest(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceID cachedJarId = new GameInstanceID("cached-jar"); + GameInstanceID requestedJarId = new GameInstanceID("requested-jar"); + writeVersionJar(repository.getLayout().getInstanceJarFile(cachedJarId), "1.20.1"); + writeVersionJar(repository.getLayout().getInstanceJarFile(requestedJarId), "1.21.1"); + + GameInstanceManifest cachedManifest = new GameInstanceManifest(instanceId).withJar(cachedJarId); + TestGameInstance cachedInstance = repository.publish(instanceId, cachedManifest); + assertEquals(GameVersionNumber.asGameVersion("1.20.1"), cachedInstance.getVersion()); + + GameInstanceManifest requestedManifest = cachedManifest.withJar(requestedJarId); + assertEquals(Optional.of("1.21.1"), repository.getGameVersion(requestedManifest)); + } + + /// Writes a minimal jar containing the version metadata consumed by [GameVersion]. + /// + /// @param jar the jar path + /// @param version the Minecraft version stored in `version.json` + private static void writeVersionJar(Path jar, String version) throws IOException { + Files.createDirectories(jar.getParent()); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(jar))) { + output.putNextEntry(new ZipEntry("version.json")); + output.write(("{\"id\":\"" + version + "\"}").getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + } + + /// Minimal repository implementation for snapshot-bound instance tests. + @NotNullByDefault + private static final class TestRepository extends DefaultGameRepository { + + /// Creates a test repository rooted at the given directory. + /// + /// @param baseDirectory the repository base directory + private TestRepository(Path baseDirectory) { + super(baseDirectory); + } + + /// {@inheritDoc} + @Override + protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { + return new DefaultGameRepositoryLayout(baseDirectory); + } + + /// {@inheritDoc} + @Override + protected TestGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest) { + return new TestGameInstance(snapshot, id, manifest); + } + + /// Publishes a snapshot containing one test instance. + /// + /// @param id the instance id + /// @param manifest the stored manifest + /// @return the published instance + private TestGameInstance publish(GameInstanceID id, GameInstanceManifest manifest) { + DefaultGameRepositorySnapshot snapshot = newSnapshot(); + TestGameInstance instance = createInstance(snapshot, id, manifest); + snapshot.put(instance); + publishSnapshot(snapshot); + return instance; + } + + /// Creates an empty mutable snapshot using the current layout. + /// + /// @return the new snapshot + private DefaultGameRepositorySnapshot newSnapshot() { + return createSnapshot(getLayout()); + } + } + + /// Minimal concrete game instance that exposes cache state to tests. + @NotNullByDefault + private static final class TestGameInstance extends DefaultGameInstance { + + /// Creates a test instance without shared session state. + /// + /// @param snapshot the owning snapshot + /// @param id the instance id + /// @param manifest the stored manifest + private TestGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest) { + super(snapshot, id, manifest); + } + + /// Creates a test instance that may reuse compatible session state. + /// + /// @param snapshot the owning snapshot + /// @param id the instance id + /// @param manifest the stored manifest + /// @param shareSession the prior snapshot member + private TestGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + TestGameInstance shareSession) { + super(snapshot, id, manifest, shareSession); + } + + /// {@inheritDoc} + @Override + protected TestGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) { + return new TestGameInstance(newSnapshot, id, manifest, this); + } + + /// {@inheritDoc} + @Override + protected TestGameInstance withManifest( + DefaultGameRepositorySnapshot newSnapshot, + GameInstanceManifest manifest) { + return new TestGameInstance(newSnapshot, id, manifest, this); + } + + /// Returns the cache without triggering version detection. + /// + /// @return the cached version, or `null` when detection has not run + private @Nullable GameVersionNumber cachedVersion() { + return version; + } + } +} From 5368601c2daedc14380bd1e8aadd2dea5847c20f Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 18:58:57 +0800 Subject: [PATCH 027/199] Refactor artifact file retrieval to use GameRepositoryLayout for improved clarity and consistency --- .../hmcl/download/forge/ForgeNewInstallTask.java | 8 ++++---- .../download/neoforge/NeoForgeOldInstallTask.java | 8 ++++---- .../jackhuang/hmcl/game/DefaultGameRepository.java | 8 ++------ .../org/jackhuang/hmcl/game/GameRepositoryLayout.java | 11 +++++++++++ 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java index 4b89a0d55ed..f14de30af3f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java @@ -110,7 +110,7 @@ public void execute() throws Exception { return; } - Path jar = gameRepository.getArtifactFile(manifest, processor.getJar()); + Path jar = gameRepository.getLayout().getArtifactFile(processor.getJar()); if (!Files.isRegularFile(jar)) throw new FileNotFoundException("Game processor file not found, should be downloaded in preprocess"); @@ -128,7 +128,7 @@ public void execute() throws Exception { List classpath = new ArrayList<>(processor.getClasspath().size() + 1); for (Artifact artifact : processor.getClasspath()) { - Path file = gameRepository.getArtifactFile(manifest, artifact); + Path file = gameRepository.getLayout().getArtifactFile(artifact); if (!Files.isRegularFile(file)) throw new Exception("Game processor dependency missing"); classpath.add(file.toString()); @@ -262,7 +262,7 @@ private String parseLiteral(String literal, Map classpath = new ArrayList<>(processor.getClasspath().size() + 1); for (Artifact artifact : processor.getClasspath()) { - Path file = gameRepository.getArtifactFile(manifest, artifact); + Path file = gameRepository.getLayout().getArtifactFile(artifact); if (!Files.isRegularFile(file)) throw new Exception("Game processor dependency missing"); classpath.add(file.toString()); @@ -246,7 +246,7 @@ private String parseLiteral(String literal, Map Date: Tue, 4 Aug 2026 19:00:25 +0800 Subject: [PATCH 028/199] Refactor getSnapshot method to return DefaultGameRepositorySnapshot for improved type safety and clarity --- .../java/org/jackhuang/hmcl/game/DefaultGameRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 dea0b0b8ac5..3663ac49e51 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -126,8 +126,8 @@ protected DefaultGameRepositorySnapshot currentSnapshot() { /// {@inheritDoc} @Override - public GameRepositorySnapshot getSnapshot() { - return snapshot.get(); + public DefaultGameRepositorySnapshot getSnapshot() { + return (DefaultGameRepositorySnapshot) snapshot.get(); } /// Returns a read-only view of the current published snapshot for JavaFX bindings. From 4448768a872dcd4fef61cee9130996f0301c3498 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 19:07:11 +0800 Subject: [PATCH 029/199] Simplify Default/HMCL game repository snapshot access with direct casts Assisted-by: grok-build:grok-4.5 --- .../hmcl/game/HMCLGameRepository.java | 30 ++++++++------- .../hmcl/game/DefaultGameRepository.java | 37 ++++++++----------- 2 files changed, 32 insertions(+), 35 deletions(-) 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 00becbe2f34..4aff94e79e3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -98,12 +98,17 @@ protected HMCLGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout @Override protected HMCLGameInstance createInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { DefaultGameInstance existing = snapshot.get(id); - if (existing instanceof HMCLGameInstance hmcl) { - return hmcl.withManifest(snapshot, manifest); + if (existing != null) { + return ((HMCLGameInstance) existing).withManifest(snapshot, manifest); } return new HMCLGameInstance(snapshot, id, manifest); } + @Override + public HMCLGameRepositorySnapshot getSnapshot() { + return (HMCLGameRepositorySnapshot) super.getSnapshot(); + } + @Override public HMCLGameRepositoryLayout getLayout() { return (HMCLGameRepositoryLayout) super.getLayout(); @@ -121,26 +126,25 @@ public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstance /// @param id the instance id /// @return the instance, or `null` when absent public @Nullable HMCLGameInstance findInstance(GameInstanceID id) { - GameInstance instance = getSnapshot().findInstance(id); - return instance instanceof HMCLGameInstance hmcl ? hmcl : null; + return (HMCLGameInstance) getSnapshot().findInstance(id); } /// Returns the instance that owns local state for the given id. /// - /// When the id is already present in the current [DefaultGameRepositorySnapshot] (including provisional - /// placeholders), that instance is returned. Otherwise a provisional [HMCLGameInstance] is - /// created and published in a new snapshot until it is promoted by a real manifest or the - /// snapshot is replaced by refresh. + /// When the id is already present in the current snapshot (including provisional placeholders), + /// that instance is returned. Otherwise a provisional [HMCLGameInstance] is created and published + /// in a new snapshot until it is promoted by a real manifest or the snapshot is replaced by + /// refresh. /// /// @param instanceId the instance id /// @return the instance used to manage settings and install-time state for the id private HMCLGameInstance resolveInstance(GameInstanceID instanceId) { DefaultGameInstance existing = findSnapshotInstance(instanceId); - if (existing instanceof HMCLGameInstance hmcl) { - return hmcl; + if (existing != null) { + return (HMCLGameInstance) existing; } - DefaultGameRepositorySnapshot newSnapshot = currentSnapshot().clone(); + HMCLGameRepositorySnapshot newSnapshot = getSnapshot().clone(); HMCLGameInstance provisional = HMCLGameInstance.provisional(newSnapshot, instanceId); newSnapshot.put(provisional); publishSnapshot(newSnapshot); @@ -589,8 +593,8 @@ public void markInstanceAsModpack(GameInstanceID instanceId) { /// @param instanceId the instance id public void undoMark(GameInstanceID instanceId) { DefaultGameInstance existing = findSnapshotInstance(instanceId); - if (existing instanceof HMCLGameInstance hmcl) { - hmcl.unmarkAsModpack(); + if (existing != null) { + ((HMCLGameInstance) existing).unmarkAsModpack(); } } 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 3663ac49e51..daba5a067f0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -92,7 +92,7 @@ private static boolean hasClassicVersion(Path baseDirectory) { } /// Published snapshot, always updated on the JavaFX application thread when the toolkit is live. - private final ObjectProperty snapshot; + private final ObjectProperty snapshot; private volatile boolean loaded; @@ -114,20 +114,13 @@ public void setBaseDirectory(Path baseDirectory) { this.loaded = false; } - /// Returns the current published repository snapshot. + /// {@inheritDoc} /// /// The returned snapshot is sealed and must not be modified. Writers must [#clone()] it, edit the /// copy, and publish the result with [#publishSnapshot(DefaultGameRepositorySnapshot)]. - /// - /// @return the current snapshot - protected DefaultGameRepositorySnapshot currentSnapshot() { - return (DefaultGameRepositorySnapshot) snapshot.get(); - } - - /// {@inheritDoc} @Override public DefaultGameRepositorySnapshot getSnapshot() { - return (DefaultGameRepositorySnapshot) snapshot.get(); + return snapshot.get(); } /// Returns a read-only view of the current published snapshot for JavaFX bindings. @@ -136,7 +129,7 @@ public DefaultGameRepositorySnapshot getSnapshot() { /// application thread so listeners may safely touch the scene graph. /// /// @return the observable snapshot property - public final ReadOnlyObjectProperty snapshotProperty() { + public final ReadOnlyObjectProperty snapshotProperty() { return snapshot; } @@ -146,7 +139,7 @@ public final ReadOnlyObjectProperty snapshotProperty() { /// (blocking the caller if publish happens off the FX thread) so that listeners run on FX and /// [#getSnapshot()] observes the new value before this method returns. /// - /// @param newSnapshot the snapshot to publish; must not already be visible as [#currentSnapshot()] + /// @param newSnapshot the snapshot to publish; must not already be visible as [#getSnapshot()] /// unless it is a freshly built replacement protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { newSnapshot.seal(); @@ -154,7 +147,7 @@ protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { } /// Sets [#snapshot] on the JavaFX application thread when possible. - private void setSnapshotOnFxThread(GameRepositorySnapshot newSnapshot) { + private void setSnapshotOnFxThread(DefaultGameRepositorySnapshot newSnapshot) { if (Platform.isFxApplicationThread()) { snapshot.set(newSnapshot); return; @@ -181,7 +174,7 @@ private void setSnapshotOnFxThread(GameRepositorySnapshot newSnapshot) { @Override public DefaultGameRepositoryLayout getLayout() { - return currentSnapshot().getLayout(); + return getSnapshot().getLayout(); } public boolean isLoaded() { @@ -200,7 +193,7 @@ public void refresh() { } protected void refreshImpl() { - DefaultGameRepositorySnapshot newSnapshot = createSnapshot(currentSnapshot().getLayout()); + DefaultGameRepositorySnapshot newSnapshot = createSnapshot(getSnapshot().getLayout()); if (hasClassicVersion(newSnapshot.getLayout().getBaseDirectory())) { GameInstanceID id = CLASSIC_MANIFEST.id(); @@ -338,7 +331,7 @@ private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, G @Override public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceException { - return currentSnapshot().getRegistered(id); + return getSnapshot().getRegistered(id); } /// Returns the instance recorded in the current snapshot for the given id, including provisional @@ -347,7 +340,7 @@ public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInsta /// @param id the instance id /// @return the instance, or `null` when absent from the current snapshot protected @Nullable DefaultGameInstance findSnapshotInstance(GameInstanceID id) { - return currentSnapshot().get(id); + return getSnapshot().get(id); } @Override @@ -369,7 +362,7 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { } try { - DefaultGameRepositorySnapshot newSnapshot = currentSnapshot().clone(); + DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); DefaultGameInstance fromHolder = newSnapshot.get(from); if (fromHolder == null || fromHolder.isProvisional()) { throw new NoSuchGameInstanceException(from); @@ -421,8 +414,8 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { return false; } - if (currentSnapshot().get(id) != null) { - DefaultGameRepositorySnapshot newSnapshot = currentSnapshot().clone(); + if (getSnapshot().get(id) != null) { + DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); newSnapshot.remove(id); publishSnapshot(newSnapshot); } @@ -597,7 +590,7 @@ public Task saveAsync(GameInstanceManifest instanceManifes Files.createDirectories(json.getParent()); JsonUtils.writeToJsonFile(json, savedManifest); - DefaultGameRepositorySnapshot newSnapshot = currentSnapshot().clone(); + DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); DefaultGameInstance existing = newSnapshot.get(savedManifest.id()); if (existing != null) { newSnapshot.put(existing.withManifest(newSnapshot, savedManifest)); @@ -645,7 +638,7 @@ public ResourcePackManager getResourcePackManager(GameInstanceID instanceId) thr @Override public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { - return currentSnapshot().resolve(manifest); + return getSnapshot().resolve(manifest); } /// Creates an empty unsealed snapshot for the given layout. From e6740406f92c2083cf8ebfd00801a556c54b9eec Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 19:08:12 +0800 Subject: [PATCH 030/199] Refactor createInstance method to simplify HMCLGameInstance creation by removing unnecessary existing instance check --- .../main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java | 4 ---- 1 file changed, 4 deletions(-) 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 4aff94e79e3..161a1bf1d75 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -97,10 +97,6 @@ protected HMCLGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout @Override protected HMCLGameInstance createInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { - DefaultGameInstance existing = snapshot.get(id); - if (existing != null) { - return ((HMCLGameInstance) existing).withManifest(snapshot, manifest); - } return new HMCLGameInstance(snapshot, id, manifest); } From 58641297cf88abe6c6eecf3107292cef9644b4ad Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 19:23:36 +0800 Subject: [PATCH 031/199] Refactor warning message to clarify ignored instance directory due to invalid ID --- .../java/org/jackhuang/hmcl/game/DefaultGameRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 daba5a067f0..dbac9063711 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -208,7 +208,7 @@ protected void refreshImpl() { try { id = new GameInstanceID(FileUtils.getName(dir)); } catch (IllegalArgumentException e) { - LOG.warning("Ignoring version folder with invalid id " + dir, e); + LOG.warning("Ignoring instance directory with invalid id " + dir, e); return Stream.empty(); } From bda256995a2ccde53b41e1715bb4ed2c2ab9fc52 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 19:36:03 +0800 Subject: [PATCH 032/199] Derive game version from ObjectProperty-held HMCLGameInstance in GameSettingsPage Assisted-by: grok-build:grok-4.5 --- .../hmcl/ui/game/GameSettingsPage.java | 117 ++++++++---------- 1 file changed, 53 insertions(+), 64 deletions(-) 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 7d21c053d8d..84b07baca81 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 @@ -90,20 +90,13 @@ public final class GameSettingsPage extends StackPane private final ObjectProperty state = new SimpleObjectProperty<>(this, "state", new State("", null, false, false, false)); private final WeakListenerHolder holder = new WeakListenerHolder(); - /// The selected game directory. - private @Nullable GameDirectory gameDirectory; - - /// The selected repository. - private @Nullable HMCLGameRepository repository; - - /// The current instance ID. - private @Nullable GameInstanceID instanceId; + /// The current game instance when editing instance settings, or `null` for preset settings. + private final ObjectProperty<@Nullable HMCLGameInstance> gameInstance = + new SimpleObjectProperty<>(this, "gameInstance"); /// The current setting. private final ObjectProperty<@Nullable S> currentSetting = new SimpleObjectProperty<>(this, "setting"); - private final ObjectProperty currentGameVersionNumber = new SimpleObjectProperty<>(this, "currentGameVersionNumber", GameVersionNumber.unknown()); - private boolean updatingJavaSetting = false; private boolean updatingSelectedJava = false; private boolean updatingParentSetting = false; @@ -794,19 +787,22 @@ public GameSettingsPage(Class settingType) { highPerformancePane.setTitle(i18n("settings.advanced.renderer.gpu_preferences")); highPerformancePane.setSubtitle(i18n("settings.advanced.windows_only")); - this.currentGameVersionNumber.addListener((o, oldValue, newValue) -> { - boolean showBackendChoose = isPresetSetting || newValue.compareTo("26.2-snapshot-2") >= 0; + InvalidationListener updateGraphicsVisibility = o -> { + GameVersionNumber version = currentGameVersion(); + boolean showBackendChoose = isPresetSetting || version.compareTo("26.2-snapshot-2") >= 0; graphicsBackendPane.setVisible(showBackendChoose); graphicsBackendPane.setManaged(showBackendChoose); - boolean showOpenGL = GraphicsAPI.OPENGL.isSupported(newValue); + boolean showOpenGL = GraphicsAPI.OPENGL.isSupported(version); openGLRendererPane.setVisible(showOpenGL); openGLRendererPane.setManaged(showOpenGL); - boolean showVulkan = GraphicsAPI.VULKAN.isSupported(newValue); + boolean showVulkan = GraphicsAPI.VULKAN.isSupported(version); vulkanRendererPane.setVisible(showVulkan); vulkanRendererPane.setManaged(showVulkan); - }); + }; + this.gameInstance.addListener(updateGraphicsVisibility); + updateGraphicsVisibility.invalidated(this.gameInstance); } var nativeLibrarySettings = new ComponentList(); @@ -1862,16 +1858,20 @@ private void bindRunningDirectoryProperty( } private boolean isCurrentInstanceModpack() { - return repository != null && instanceId != null && repository.isModpack(instanceId); + HMCLGameInstance gameInstance = this.gameInstance.get(); + return gameInstance != null && gameInstance.getRepository().isModpack(gameInstance.getId()); } /// Returns the current instance version root displayed for modpack running directories. private String getCurrentInstanceVersionRoot() { - if (repository == null || instanceId == null) { - return ""; - } + HMCLGameInstance gameInstance = this.gameInstance.get(); + return gameInstance != null ? gameInstance.getInstanceRoot().toString() : ""; + } - return repository.getLayout().getInstanceRoot(instanceId).toString(); + /// Returns the Minecraft version of the loaded instance, or [GameVersionNumber#unknown()] for presets. + private GameVersionNumber currentGameVersion() { + HMCLGameInstance gameInstance = this.gameInstance.get(); + return gameInstance != null ? gameInstance.getVersion() : GameVersionNumber.unknown(); } /// Keeps a listener attached to the current instance's parent preset property. @@ -2591,8 +2591,9 @@ private GameSettings getEffectiveInheritableSource( /// Returns the runtime parent preset for an instance, including the game directory's migrated preset fallback. private GameSettings.Preset getEffectiveParentGameSettings(GameSettings.Instance instance) { - if (repository != null) { - return repository.getParentGameSettings(instance); + HMCLGameInstance gameInstance = this.gameInstance.get(); + if (gameInstance != null) { + return gameInstance.getRepository().getParentGameSettings(instance); } return getExplicitParentGameSettings(instance); @@ -2633,26 +2634,20 @@ public ReadOnlyObjectProperty stateProperty() { @SuppressWarnings("unchecked") @Override public void loadInstance(HMCLGameInstance.Optional instance) { - HMCLGameRepository repository = instance.repository(); - @Nullable GameInstanceID instanceId = instance.instanceId(); - this.gameDirectory = repository.getGameDirectory(); - this.repository = repository; - this.instanceId = instanceId; + HMCLGameInstance gameInstance = instance.instance(); + this.gameInstance.set(gameInstance); - assert isPresetSetting == (instanceId == null); + assert isPresetSetting == (gameInstance == null); - if (instanceId != null) { - this.currentGameVersionNumber.set(GameVersionNumber.asGameVersion(repository.getGameVersion(instanceId))); - - @Nullable GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId); + if (gameInstance != null) { + @Nullable GameSettings.Instance setting = gameInstance.getSettingsOrCreate(); this.currentSetting.set((S) setting); setSettingsReadOnly( - setting == null || repository.isInstanceGameSettingsReadOnly(instanceId), + setting == null || gameInstance.isSettingsReadOnly(), i18n("settings.game.instance_settings.unsupported"), setting != null ? this::forceOverwriteInstanceGameSettings : null); loadIcon(); } else { - this.currentGameVersionNumber.set(GameVersionNumber.unknown()); this.currentSetting.set((S) SettingsManager.getDefaultGameSettingsPresetOrCreate()); setSettingsReadOnly( SettingsManager.isGameSettingsReadOnly(), @@ -2661,11 +2656,6 @@ public void loadInstance(HMCLGameInstance.Optional instance) { } } - /// Returns the loaded instance ID, or `null` when this page edits preset settings. - private @Nullable GameInstanceID getLoadedInstanceId() { - return instanceId == null ? null : instanceId; - } - /// Updates the page read-only state used when settings cannot be saved safely. /// /// @param readOnly whether the current settings should be displayed read-only @@ -2705,13 +2695,13 @@ private void setSettingsReadOnly(boolean readOnly, String message, @Nullable Run /// Backs up and overwrites the current instance's `instance-game-settings.json`. private void forceOverwriteInstanceGameSettings() { - @Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId(); - if (repository == null || loadedInstanceId == null) { + HMCLGameInstance gameInstance = this.gameInstance.get(); + if (gameInstance == null) { return; } Controllers.confirmBackupAndOverwrite(i18n("settings.game.instance_settings.unsupported"), () -> { - repository.forceOverwriteInstanceGameSettings(loadedInstanceId); + gameInstance.forceOverwriteSettings(); setSettingsReadOnly(false, ""); }); } @@ -2725,11 +2715,12 @@ private void forceOverwriteGameSettings() { } private void loadIcon() { - @Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId(); - if (repository == null || loadedInstanceId == null) + HMCLGameInstance gameInstance = this.gameInstance.get(); + if (gameInstance == null) { return; + } - iconPickerItem.setImage(repository.getInstanceIconImage(loadedInstanceId)); + iconPickerItem.setImage(gameInstance.getRepository().getInstanceIconImage(gameInstance.getId())); } /// Refreshes Java selection controls and keeps inherited parent Java properties observed. @@ -2793,17 +2784,20 @@ private void initializeSelectedJava() { private void initJavaSubtitle() { S setting = currentSetting.get(); - if (setting == null || gameDirectory == null) + if (setting == null) { return; + } initializeSelectedJava(); JavaVersionType javaVersionType = setting.javaTypeProperty().getValue(); - @Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId(); - GameSettings.Effective effectiveSetting = loadedInstanceId != null && repository != null ? repository.getEffectiveGameSettings(loadedInstanceId) : null; + HMCLGameInstance gameInstance = this.gameInstance.get(); + GameSettings.Effective effectiveSetting = gameInstance != null + ? gameInstance.getRepository().getEffectiveGameSettings(gameInstance.getId()) + : null; JavaVersionType effectiveJavaVersionType = effectiveSetting != null ? effectiveSetting.getInheritable(GameSettings::javaTypeProperty) : javaVersionType; boolean autoSelected = effectiveJavaVersionType == JavaVersionType.AUTO || effectiveJavaVersionType == JavaVersionType.VERSION; - if (instanceId == null && autoSelected) { + if (gameInstance == null && autoSelected) { javaSublist.setDescription(i18n("settings.game.java_directory.auto")); return; } @@ -2815,13 +2809,10 @@ private void initJavaSubtitle() { } if (JavaManager.isInitialized()) { - GameVersionNumber gameVersionNumber = this.currentGameVersionNumber.get(); - GameInstanceManifest manifest; - if (this.instanceId == null) { - manifest = null; - } else { - manifest = repository != null && loadedInstanceId != null ? repository.getResolvedInstanceManifest(loadedInstanceId).launchManifest() : null; - } + GameVersionNumber gameVersionNumber = currentGameVersion(); + GameInstanceManifest manifest = gameInstance != null + ? gameInstance.getResolvedManifest().launchManifest() + : null; try { JavaRuntime java = effectiveSetting != null @@ -2841,10 +2832,7 @@ private void initJavaSubtitle() { } private void onExploreIcon() { - if (repository == null || instanceId == null) - return; - - HMCLGameInstance gameInstance = repository.findInstance(instanceId); + HMCLGameInstance gameInstance = this.gameInstance.get(); if (gameInstance == null) { return; } @@ -2852,12 +2840,13 @@ private void onExploreIcon() { } private void onDeleteIcon() { - @Nullable GameInstanceID loadedInstanceId = getLoadedInstanceId(); - if (repository == null || loadedInstanceId == null) + HMCLGameInstance gameInstance = this.gameInstance.get(); + if (gameInstance == null) { return; + } - repository.deleteIconFile(loadedInstanceId); - GameSettings.Instance localGameSettings = repository.getInstanceGameSettingsOrCreate(loadedInstanceId); + gameInstance.getRepository().deleteIconFile(gameInstance.getId()); + GameSettings.Instance localGameSettings = gameInstance.getSettingsOrCreate(); if (localGameSettings != null) { localGameSettings.iconProperty().setValue(GameInstanceIconType.DEFAULT); } From 3aa6abaf14826b65436c033c6d436e4c7220b3a8 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:19:28 +0800 Subject: [PATCH 033/199] Record non-conventional instance JSON and jar paths instead of renaming on refresh Assisted-by: grok-build:grok-4.5 --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 24 +- .../hmcl/game/HMCLGameRepository.java | 9 +- .../hmcl/game/DefaultGameInstance.java | 75 +++++- .../hmcl/game/DefaultGameRepository.java | 218 ++++++++++++------ .../org/jackhuang/hmcl/game/GameInstance.java | 5 + .../hmcl/game/DefaultGameInstanceTest.java | 46 +++- .../hmcl/game/GameInstanceManifestTest.java | 18 +- 7 files changed, 296 insertions(+), 99 deletions(-) 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 b2e8d33aa3a..91ed1b845fb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -69,7 +69,23 @@ public class HMCLGameInstance extends DefaultGameInstance { /// @param id the instance id /// @param manifest the stored instance manifest protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { - this(snapshot, id, manifest, false); + this(snapshot, id, manifest, null, null, false); + } + + /// Creates a registered instance with optional non-conventional storage paths. + /// + /// @param snapshot the repository snapshot that owns this instance + /// @param id the instance id + /// @param manifest the stored instance manifest + /// @param manifestFile the actual manifest JSON path, or `null` for the layout default + /// @param jarFile the actual primary jar path, or `null` for the layout default + protected HMCLGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile, + @Nullable Path jarFile) { + this(snapshot, id, manifest, manifestFile, jarFile, false); } /// Creates a provisional instance used before a real manifest is indexed. @@ -78,15 +94,17 @@ protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceI /// @param id the instance id /// @return a provisional instance with an empty placeholder manifest static HMCLGameInstance provisional(DefaultGameRepositorySnapshot snapshot, GameInstanceID id) { - return new HMCLGameInstance(snapshot, id, new GameInstanceManifest(id), true); + return new HMCLGameInstance(snapshot, id, new GameInstanceManifest(id), null, null, true); } private HMCLGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, + @Nullable Path manifestFile, + @Nullable Path jarFile, boolean provisional) { - super(snapshot, id, manifest); + super(snapshot, id, manifest, manifestFile, jarFile); this.provisional = provisional; } 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 161a1bf1d75..02ebb8dca5e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -96,8 +96,13 @@ protected HMCLGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout } @Override - protected HMCLGameInstance createInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { - return new HMCLGameInstance(snapshot, id, manifest); + protected HMCLGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile, + @Nullable Path jarFile) { + return new HMCLGameInstance(snapshot, id, manifest, manifestFile, jarFile); } @Override 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 443a3992706..18e2ad43b62 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -32,8 +32,8 @@ /// Default snapshot member for an official-layout game instance. /// -/// Index fields (`id`, `manifest`, layout binding) belong to a -/// [DefaultGameRepositorySnapshot]. Session services such as [#getModManager()] and +/// Index fields (`id`, `manifest`, layout binding, and optional non-conventional file paths) belong +/// to a [DefaultGameRepositorySnapshot]. Session services such as [#getModManager()] and /// [#getResourcePackManager()] are lazy and are shared across copies only while the instance ID /// and stored manifest remain unchanged, so ordinary COW publishes preserve caches without leaking /// manifest-derived state into an updated instance. @@ -45,6 +45,16 @@ public abstract class DefaultGameInstance implements GameInstance { protected final DefaultGameRepositoryLayout layout; protected final GameInstanceID id; protected final GameInstanceManifest manifest; + + /// Non-conventional manifest file path discovered at load time, or `null` for the layout default. + protected final @Nullable Path manifestFile; + + /// Non-conventional primary jar path discovered at load time, or `null` for the layout default. + /// + /// Used only when the launch manifest does not redirect to another version's jar via + /// [GameInstanceManifest#jar()]. + protected final @Nullable Path jarFile; + protected GameInstanceManifest.@Nullable Resolved resolvedManifest; /// Cached Minecraft game version detected from this instance's primary jar. @@ -63,17 +73,35 @@ protected DefaultGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { + this(snapshot, id, manifest, null, null); + } + + /// Creates an instance with optional non-conventional storage paths. + /// + /// @param snapshot the snapshot that owns this instance + /// @param id the instance id (directory name under the official layout) + /// @param manifest the stored instance manifest + /// @param manifestFile the actual manifest JSON path, or `null` for [DefaultGameRepositoryLayout#getInstanceJson] + /// @param jarFile the actual primary jar path, or `null` for [DefaultGameRepositoryLayout#getInstanceJarFile] + protected DefaultGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile, + @Nullable Path jarFile) { this.snapshot = snapshot; this.repository = snapshot.getRepository(); this.layout = snapshot.getLayout(); this.id = id; this.manifest = manifest; + this.manifestFile = manifestFile; + this.jarFile = jarFile; } - /// Creates an instance that may reuse session state from another snapshot wrapper. + /// Creates an instance that may reuse session state and storage paths from another snapshot wrapper. /// - /// Cached version and manager state is copied only when `id` and `manifest` equal those of - /// `shareSession`; otherwise the new wrapper starts with empty derived state. + /// Storage paths are copied when `id` equals that of `shareSession`. Cached version and manager + /// state is copied only when `id` and `manifest` also equal those of `shareSession`. /// /// @param snapshot the snapshot that will own the copy /// @param id the instance id @@ -84,7 +112,12 @@ protected DefaultGameInstance( GameInstanceID id, GameInstanceManifest manifest, DefaultGameInstance shareSession) { - this(snapshot, id, manifest); + this( + snapshot, + id, + manifest, + Objects.equals(id, shareSession.id) ? shareSession.manifestFile : null, + Objects.equals(id, shareSession.id) ? shareSession.jarFile : null); if (Objects.equals(id, shareSession.id) && Objects.equals(manifest, shareSession.manifest)) { this.version = shareSession.version; this.modManager = shareSession.modManager; @@ -204,11 +237,39 @@ public Path getInstanceRoot() { return layout.getInstanceRoot(id); } + /// {@inheritDoc} + /// + /// When a non-conventional path was discovered while loading this instance, that path is + /// returned; otherwise the layout default `versions//.json` is used. + @Override + public Path getManifestFile() { + return manifestFile != null ? manifestFile : layout.getInstanceJson(id); + } + + /// {@inheritDoc} + /// + /// When the launch manifest redirects to another version via [GameInstanceManifest#jar()], the + /// jar is resolved through the layout (or that other instance when present). Otherwise a + /// non-conventional jar path discovered at load time is preferred over the layout default. @Override public Path getInstanceJarFile() { GameInstanceManifest launchManifest = getResolvedManifest().launchManifest(); GameInstanceID jarId = Optional.ofNullable(launchManifest.jar()).orElse(launchManifest.id()); - return layout.getInstanceJarFile(jarId); + if (!jarId.equals(id)) { + DefaultGameInstance other = snapshot.findInstance(jarId); + if (other != null) { + return other.getOwnJarFile(); + } + return layout.getInstanceJarFile(jarId); + } + return getOwnJarFile(); + } + + /// Returns this instance's own primary jar without following `jar` inheritance. + /// + /// @return the jar path stored on this instance or the layout default + Path getOwnJarFile() { + return jarFile != null ? jarFile : layout.getInstanceJarFile(id); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index dbac9063711..a4b661d0089 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -41,7 +41,10 @@ import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.*; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.TimeUnit; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -49,6 +52,8 @@ @NotNullByDefault public abstract class DefaultGameRepository implements GameRepository { + private static final ExecutorService POOL = Lang.threadPool("DefaultGameRepository", true, 4, 10, TimeUnit.SECONDS); + private static final GameInstanceManifest CLASSIC_MANIFEST = new GameInstanceManifest( new GameInstanceID("Classic"), "${auth_player_name} ${auth_session} --workDir ${game_directory}", @@ -194,85 +199,31 @@ public void refresh() { protected void refreshImpl() { DefaultGameRepositorySnapshot newSnapshot = createSnapshot(getSnapshot().getLayout()); + DefaultGameRepositoryLayout layout = newSnapshot.getLayout(); - if (hasClassicVersion(newSnapshot.getLayout().getBaseDirectory())) { + if (hasClassicVersion(layout.getBaseDirectory())) { GameInstanceID id = CLASSIC_MANIFEST.id(); newSnapshot.put(createInstance(newSnapshot, id, CLASSIC_MANIFEST)); } - Path versionsDir = newSnapshot.getLayout().getBaseDirectory().resolve("versions"); - if (Files.isDirectory(versionsDir)) { - try (Stream stream = Files.list(versionsDir)) { - stream.parallel().filter(Files::isDirectory).flatMap(dir -> { - GameInstanceID id; - try { - id = new GameInstanceID(FileUtils.getName(dir)); - } catch (IllegalArgumentException e) { - LOG.warning("Ignoring instance directory with invalid id " + dir, e); - return Stream.empty(); - } - - Path json = dir.resolve(id + ".json"); - - if (Files.notExists(json)) { - List jsons = FileUtils.listFilesByExtension(dir, "json"); - if (jsons.size() == 1) { - LOG.info("Renaming json file " + jsons.get(0) + " to " + json); - - try { - Files.move(jsons.get(0), json); - } catch (IOException e) { - LOG.warning("Cannot rename json file, ignoring version " + id, e); - return Stream.empty(); - } - - Path jar = dir.resolve(FileUtils.getNameWithoutExtension(jsons.get(0)) + ".jar"); - if (Files.exists(jar)) { - try { - Files.move(jar, dir.resolve(id + ".jar")); - } catch (IOException e) { - LOG.warning("Cannot rename jar file, ignoring version " + id, e); - return Stream.empty(); - } - } - } else { - LOG.info("No available json file found, ignoring version " + id); - return Stream.empty(); - } - } - - GameInstanceManifest manifest; - try { - manifest = readInstanceManifest(json); - } catch (Exception e) { - LOG.warning("Malformed version json " + id, e); - if (EventBus.EVENT_BUS.fireEvent(new GameJsonParseFailedEvent(this, json, id.id())) != Event.Result.ALLOW) { - return Stream.empty(); - } - - try { - manifest = readInstanceManifest(json); - } catch (Exception e2) { - LOG.error("User corrected version json is still malformed", e2); - return Stream.empty(); - } + Path instancesDir = layout.getBaseDirectory().resolve("versions"); + if (Files.isDirectory(instancesDir)) { + try (Stream stream = Files.list(instancesDir)) { + List> futures = stream + .filter(Files::isDirectory) + .map(dir -> CompletableFuture.supplyAsync( + Lang.wrap(() -> loadInstanceDirectory(newSnapshot, dir)), + POOL)) + .toList(); + + for (CompletableFuture<@Nullable DefaultGameInstance> future : futures) { + DefaultGameInstance instance = future.join(); + if (instance != null) { + newSnapshot.put(instance); } - - if (!id.equals(manifest.id())) { - try { - moveInstanceFiles(newSnapshot.getLayout().getBaseDirectory(), id, manifest.id()); - } catch (IOException e) { - LOG.warning("Ignoring instance " + manifest.id() - + " because instance id does not match folder name " + id - + ", and we cannot correct it.", e); - return Stream.empty(); - } - } - - return Stream.of(manifest); - }).forEachOrdered(it -> newSnapshot.put(createInstance(newSnapshot, it.id(), it))); + } } catch (IOException e) { - LOG.warning("Failed to load versions from " + versionsDir, e); + LOG.warning("Failed to load versions from " + instancesDir, e); } } @@ -293,6 +244,86 @@ protected void refreshImpl() { publishSnapshot(newSnapshot); } + /// Loads one instance directory without renaming on-disk JSON or jar files. + /// + /// When the conventional `versions//.json` is missing but the directory contains exactly + /// one JSON file, that file and its sibling jar (same base name) are recorded on the instance. + /// + /// @param snapshot the unsealed snapshot that will own the instance + /// @param dir the instance directory under `versions/` + /// @return the loaded instance, or `null` when the directory should be ignored + private @Nullable DefaultGameInstance loadInstanceDirectory(DefaultGameRepositorySnapshot snapshot, Path dir) { + GameInstanceID id; + try { + id = new GameInstanceID(FileUtils.getName(dir)); + } catch (IllegalArgumentException e) { + LOG.warning("Ignoring instance directory with invalid id " + dir, e); + return null; + } + + DefaultGameRepositoryLayout layout = snapshot.getLayout(); + Path conventionalJson = layout.getInstanceJson(id); + Path conventionalJar = layout.getInstanceJarFile(id); + + Path json; + @Nullable Path jar; + @Nullable Path manifestFileOverride = null; + @Nullable Path jarFileOverride = null; + + if (Files.isRegularFile(conventionalJson)) { + json = conventionalJson; + jar = Files.isRegularFile(conventionalJar) ? conventionalJar : null; + } else { + List jsons = FileUtils.listFilesByExtension(dir, "json"); + if (jsons.size() != 1) { + LOG.info("No available json file found, ignoring instance " + id); + return null; + } + + json = jsons.get(0); + Path siblingJar = dir.resolve(FileUtils.getNameWithoutExtension(json) + ".jar"); + jar = Files.isRegularFile(siblingJar) ? siblingJar : null; + + if (!json.equals(conventionalJson)) { + manifestFileOverride = json; + } + if (jar != null && !jar.equals(conventionalJar)) { + jarFileOverride = jar; + } else if (jar == null && !siblingJar.equals(conventionalJar)) { + // Remember the expected sibling path even when the jar is not present yet. + jarFileOverride = siblingJar; + } + + LOG.info("Using non-conventional instance files for " + id + + ": manifest=" + json + + (jar != null ? ", jar=" + jar : "")); + } + + GameInstanceManifest manifest; + try { + manifest = readInstanceManifest(json); + } catch (Exception e) { + LOG.warning("Malformed version json " + id, e); + if (EventBus.EVENT_BUS.fireEvent(new GameJsonParseFailedEvent(this, json, id.id())) != Event.Result.ALLOW) { + return null; + } + + try { + manifest = readInstanceManifest(json); + } catch (Exception e2) { + LOG.error("User corrected version json is still malformed", e2); + return null; + } + } + + // Directory name is the repository identity; keep the on-disk files untouched. + if (!id.equals(manifest.id())) { + manifest = manifest.withId(id); + } + + return createInstance(snapshot, id, manifest, manifestFileOverride, jarFileOverride); + } + private static GameInstanceManifest readInstanceManifest(Path json) throws IOException, JsonParseException { GameInstanceManifest manifest = JsonUtils.fromJsonFile(json, GameInstanceManifest.class); if (manifest == null) { @@ -352,6 +383,10 @@ public Path getRunDirectory(GameInstanceID instanceId) { 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); } @@ -492,11 +527,18 @@ public Optional getGameVersion(GameInstanceManifest manifest) { } } - /// Returns the official version manifest file for an instance. + /// 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 path `versions//.json` below the base directory + /// @return the manifest JSON path public Path getInstanceJson(GameInstanceID instanceId) { + DefaultGameInstance instance = findSnapshotInstance(instanceId); + if (instance != null) { + return instance.getManifestFile(); + } return getLayout().getInstanceJson(instanceId); } @@ -649,6 +691,32 @@ protected DefaultGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayo return new DefaultGameRepositorySnapshot(this, layout); } - protected abstract DefaultGameInstance createInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest); + /// Creates a conventional instance with layout-default storage paths. + /// + /// @param snapshot the snapshot that will own the instance + /// @param id the instance id + /// @param manifest the stored instance manifest + /// @return the new instance + protected final DefaultGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest) { + return createInstance(snapshot, id, manifest, null, null); + } + + /// Creates an instance, optionally recording non-conventional storage paths. + /// + /// @param snapshot the snapshot that will own the instance + /// @param id the instance id + /// @param manifest the stored instance manifest + /// @param manifestFile the actual manifest JSON path, or `null` for the layout default + /// @param jarFile the actual primary jar path, or `null` for the layout default + /// @return the new instance + protected abstract DefaultGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile, + @Nullable Path jarFile); } 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 2a15a3b37f8..09656fe1cd7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -66,6 +66,11 @@ default GameInstanceManifest getLaunchManifest() { /// @return the instance root directory Path getInstanceRoot(); + /// Returns the stored instance manifest file for this instance. + /// + /// @return the manifest JSON path + Path getManifestFile(); + /// Returns the primary client jar selected by the resolved launch manifest. /// /// @return the primary client jar path 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 c77a8663cd7..49c96c904a9 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -99,6 +99,30 @@ public void testExplicitManifestDoesNotReuseDifferentCachedManifest(@TempDir Pat assertEquals(Optional.of("1.21.1"), repository.getGameVersion(requestedManifest)); } + /// Non-conventional JSON/jar basenames are kept on disk and recorded on the instance. + @Test + public void testRefreshRecordsNonConventionalStoragePaths(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID folderId = new GameInstanceID("MyInstance"); + Path instanceDir = repository.getLayout().getInstanceRoot(folderId); + Files.createDirectories(instanceDir); + + Path json = instanceDir.resolve("1.20.1.json"); + Path jar = instanceDir.resolve("1.20.1.jar"); + Files.writeString(json, "{\"id\":\"1.20.1\",\"mainClass\":\"net.minecraft.client.main.Main\",\"libraries\":[]}"); + writeVersionJar(jar, "1.20.1"); + + repository.refresh(); + + DefaultGameInstance instance = repository.getInstance(folderId); + assertEquals(json, instance.getManifestFile()); + assertEquals(jar, instance.getInstanceJarFile()); + assertEquals(GameVersionNumber.asGameVersion("1.20.1"), instance.getVersion()); + assertEquals(folderId, instance.getId()); + assertEquals(folderId, instance.getManifest().id()); + assertEquals(json, repository.getInstanceJson(folderId)); + } + /// Writes a minimal jar containing the version metadata consumed by [GameVersion]. /// /// @param jar the jar path @@ -134,8 +158,10 @@ protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { protected TestGameInstance createInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, - GameInstanceManifest manifest) { - return new TestGameInstance(snapshot, id, manifest); + GameInstanceManifest manifest, + @Nullable Path manifestFile, + @Nullable Path jarFile) { + return new TestGameInstance(snapshot, id, manifest, manifestFile, jarFile); } /// Publishes a snapshot containing one test instance. @@ -145,7 +171,7 @@ protected TestGameInstance createInstance( /// @return the published instance private TestGameInstance publish(GameInstanceID id, GameInstanceManifest manifest) { DefaultGameRepositorySnapshot snapshot = newSnapshot(); - TestGameInstance instance = createInstance(snapshot, id, manifest); + TestGameInstance instance = (TestGameInstance) createInstance(snapshot, id, manifest); snapshot.put(instance); publishSnapshot(snapshot); return instance; @@ -165,14 +191,18 @@ private static final class TestGameInstance extends DefaultGameInstance { /// Creates a test instance without shared session state. /// - /// @param snapshot the owning snapshot - /// @param id the instance id - /// @param manifest the stored manifest + /// @param snapshot the owning snapshot + /// @param id the instance id + /// @param manifest the stored manifest + /// @param manifestFile non-conventional manifest path, or `null` + /// @param jarFile non-conventional jar path, or `null` private TestGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, - GameInstanceManifest manifest) { - super(snapshot, id, manifest); + GameInstanceManifest manifest, + @Nullable Path manifestFile, + @Nullable Path jarFile) { + super(snapshot, id, manifest, manifestFile, jarFile); } /// Creates a test instance that may reuse compatible session state. 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 11c3e91e0d7..24e36723256 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java @@ -61,10 +61,20 @@ protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { } @Override - protected DefaultGameInstance createInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { + protected DefaultGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile, + @Nullable Path jarFile) { final class MyGameInstance extends DefaultGameInstance { - MyGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { - super(snapshot, id, manifest); + MyGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile, + @Nullable Path jarFile) { + super(snapshot, id, manifest, manifestFile, jarFile); } MyGameInstance( @@ -86,7 +96,7 @@ protected DefaultGameInstance withManifest(DefaultGameRepositorySnapshot newSnap } } - return new MyGameInstance(snapshot, id, manifest); + return new MyGameInstance(snapshot, id, manifest, manifestFile, jarFile); } }.resolve(manifest); From e8419885dd3c885b2ea908aa5a73bb3d0aeb67df Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:23:33 +0800 Subject: [PATCH 034/199] Handle exceptions when loading game instances to improve error logging --- .../org/jackhuang/hmcl/game/DefaultGameRepository.java | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) 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 a4b661d0089..6ccb4cba36d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -217,9 +217,13 @@ protected void refreshImpl() { .toList(); for (CompletableFuture<@Nullable DefaultGameInstance> future : futures) { - DefaultGameInstance instance = future.join(); - if (instance != null) { - newSnapshot.put(instance); + try { + DefaultGameInstance instance = future.join(); + if (instance != null) { + newSnapshot.put(instance); + } + } catch (Exception e) { + LOG.warning("Failed to load instance", e); } } } catch (IOException e) { From b3d45dbfb894b91acf2fdadf35411bfebf044ab5 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:25:53 +0800 Subject: [PATCH 035/199] Derive instance jar path from recorded manifest file instead of storing jar separately Assisted-by: grok-build:grok-4.5 --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 15 +++----- .../hmcl/game/HMCLGameRepository.java | 5 +-- .../hmcl/game/DefaultGameInstance.java | 38 +++++++++---------- .../hmcl/game/DefaultGameRepository.java | 30 ++++----------- .../hmcl/game/DefaultGameInstanceTest.java | 11 ++---- .../hmcl/game/GameInstanceManifestTest.java | 10 ++--- 6 files changed, 42 insertions(+), 67 deletions(-) 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 91ed1b845fb..e871904d0bb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -69,23 +69,21 @@ public class HMCLGameInstance extends DefaultGameInstance { /// @param id the instance id /// @param manifest the stored instance manifest protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { - this(snapshot, id, manifest, null, null, false); + this(snapshot, id, manifest, null, false); } - /// Creates a registered instance with optional non-conventional storage paths. + /// Creates a registered instance with an optional non-conventional manifest path. /// /// @param snapshot the repository snapshot that owns this instance /// @param id the instance id /// @param manifest the stored instance manifest /// @param manifestFile the actual manifest JSON path, or `null` for the layout default - /// @param jarFile the actual primary jar path, or `null` for the layout default protected HMCLGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, - @Nullable Path manifestFile, - @Nullable Path jarFile) { - this(snapshot, id, manifest, manifestFile, jarFile, false); + @Nullable Path manifestFile) { + this(snapshot, id, manifest, manifestFile, false); } /// Creates a provisional instance used before a real manifest is indexed. @@ -94,7 +92,7 @@ protected HMCLGameInstance( /// @param id the instance id /// @return a provisional instance with an empty placeholder manifest static HMCLGameInstance provisional(DefaultGameRepositorySnapshot snapshot, GameInstanceID id) { - return new HMCLGameInstance(snapshot, id, new GameInstanceManifest(id), null, null, true); + return new HMCLGameInstance(snapshot, id, new GameInstanceManifest(id), null, true); } private HMCLGameInstance( @@ -102,9 +100,8 @@ private HMCLGameInstance( GameInstanceID id, GameInstanceManifest manifest, @Nullable Path manifestFile, - @Nullable Path jarFile, boolean provisional) { - super(snapshot, id, manifest, manifestFile, jarFile); + super(snapshot, id, manifest, manifestFile); this.provisional = provisional; } 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 02ebb8dca5e..5cf4ab202e9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -100,9 +100,8 @@ protected HMCLGameInstance createInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, - @Nullable Path manifestFile, - @Nullable Path jarFile) { - return new HMCLGameInstance(snapshot, id, manifest, manifestFile, jarFile); + @Nullable Path manifestFile) { + return new HMCLGameInstance(snapshot, id, manifest, manifestFile); } @Override 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 18e2ad43b62..254d6db860e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -19,6 +19,7 @@ import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; +import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -47,13 +48,10 @@ public abstract class DefaultGameInstance implements GameInstance { protected final GameInstanceManifest manifest; /// Non-conventional manifest file path discovered at load time, or `null` for the layout default. - protected final @Nullable Path manifestFile; - - /// Non-conventional primary jar path discovered at load time, or `null` for the layout default. /// - /// Used only when the launch manifest does not redirect to another version's jar via - /// [GameInstanceManifest#jar()]. - protected final @Nullable Path jarFile; + /// When set, this instance's own primary jar is the sibling path with the same base name and a + /// `.jar` extension. + protected final @Nullable Path manifestFile; protected GameInstanceManifest.@Nullable Resolved resolvedManifest; @@ -73,34 +71,31 @@ protected DefaultGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { - this(snapshot, id, manifest, null, null); + this(snapshot, id, manifest, (Path) null); } - /// Creates an instance with optional non-conventional storage paths. + /// Creates an instance with an optional non-conventional manifest path. /// /// @param snapshot the snapshot that owns this instance /// @param id the instance id (directory name under the official layout) /// @param manifest the stored instance manifest /// @param manifestFile the actual manifest JSON path, or `null` for [DefaultGameRepositoryLayout#getInstanceJson] - /// @param jarFile the actual primary jar path, or `null` for [DefaultGameRepositoryLayout#getInstanceJarFile] protected DefaultGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, - @Nullable Path manifestFile, - @Nullable Path jarFile) { + @Nullable Path manifestFile) { this.snapshot = snapshot; this.repository = snapshot.getRepository(); this.layout = snapshot.getLayout(); this.id = id; this.manifest = manifest; this.manifestFile = manifestFile; - this.jarFile = jarFile; } /// Creates an instance that may reuse session state and storage paths from another snapshot wrapper. /// - /// Storage paths are copied when `id` equals that of `shareSession`. Cached version and manager + /// The manifest path is copied when `id` equals that of `shareSession`. Cached version and manager /// state is copied only when `id` and `manifest` also equal those of `shareSession`. /// /// @param snapshot the snapshot that will own the copy @@ -116,8 +111,7 @@ protected DefaultGameInstance( snapshot, id, manifest, - Objects.equals(id, shareSession.id) ? shareSession.manifestFile : null, - Objects.equals(id, shareSession.id) ? shareSession.jarFile : null); + Objects.equals(id, shareSession.id) ? shareSession.manifestFile : null); if (Objects.equals(id, shareSession.id) && Objects.equals(manifest, shareSession.manifest)) { this.version = shareSession.version; this.modManager = shareSession.modManager; @@ -249,8 +243,8 @@ public Path getManifestFile() { /// {@inheritDoc} /// /// When the launch manifest redirects to another version via [GameInstanceManifest#jar()], the - /// jar is resolved through the layout (or that other instance when present). Otherwise a - /// non-conventional jar path discovered at load time is preferred over the layout default. + /// jar is resolved through the layout (or that other instance when present). Otherwise this + /// instance's own jar is returned from [#getOwnJarFile()]. @Override public Path getInstanceJarFile() { GameInstanceManifest launchManifest = getResolvedManifest().launchManifest(); @@ -267,9 +261,15 @@ public Path getInstanceJarFile() { /// Returns this instance's own primary jar without following `jar` inheritance. /// - /// @return the jar path stored on this instance or the layout default + /// When a non-conventional manifest path is recorded, the jar is the sibling path with the same + /// base name. Otherwise the layout default `versions//.jar` is used. + /// + /// @return the jar path derived from the manifest file or the layout default Path getOwnJarFile() { - return jarFile != null ? jarFile : layout.getInstanceJarFile(id); + if (manifestFile != null) { + return manifestFile.resolveSibling(FileUtils.getNameWithoutExtension(manifestFile) + ".jar"); + } + return layout.getInstanceJarFile(id); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 6ccb4cba36d..3ea396f96e5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -251,7 +251,8 @@ protected void refreshImpl() { /// Loads one instance directory without renaming on-disk JSON or jar files. /// /// When the conventional `versions//.json` is missing but the directory contains exactly - /// one JSON file, that file and its sibling jar (same base name) are recorded on the instance. + /// one JSON file, that manifest path is recorded on the instance. The primary jar is derived as + /// the sibling path with the same base name. /// /// @param snapshot the unsealed snapshot that will own the instance /// @param dir the instance directory under `versions/` @@ -267,16 +268,12 @@ protected void refreshImpl() { DefaultGameRepositoryLayout layout = snapshot.getLayout(); Path conventionalJson = layout.getInstanceJson(id); - Path conventionalJar = layout.getInstanceJarFile(id); Path json; - @Nullable Path jar; @Nullable Path manifestFileOverride = null; - @Nullable Path jarFileOverride = null; if (Files.isRegularFile(conventionalJson)) { json = conventionalJson; - jar = Files.isRegularFile(conventionalJar) ? conventionalJar : null; } else { List jsons = FileUtils.listFilesByExtension(dir, "json"); if (jsons.size() != 1) { @@ -285,22 +282,11 @@ protected void refreshImpl() { } json = jsons.get(0); - Path siblingJar = dir.resolve(FileUtils.getNameWithoutExtension(json) + ".jar"); - jar = Files.isRegularFile(siblingJar) ? siblingJar : null; - if (!json.equals(conventionalJson)) { manifestFileOverride = json; } - if (jar != null && !jar.equals(conventionalJar)) { - jarFileOverride = jar; - } else if (jar == null && !siblingJar.equals(conventionalJar)) { - // Remember the expected sibling path even when the jar is not present yet. - jarFileOverride = siblingJar; - } - LOG.info("Using non-conventional instance files for " + id - + ": manifest=" + json - + (jar != null ? ", jar=" + jar : "")); + LOG.info("Using non-conventional instance manifest for " + id + ": " + json); } GameInstanceManifest manifest; @@ -325,7 +311,7 @@ protected void refreshImpl() { manifest = manifest.withId(id); } - return createInstance(snapshot, id, manifest, manifestFileOverride, jarFileOverride); + return createInstance(snapshot, id, manifest, manifestFileOverride); } private static GameInstanceManifest readInstanceManifest(Path json) throws IOException, JsonParseException { @@ -705,22 +691,20 @@ protected final DefaultGameInstance createInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { - return createInstance(snapshot, id, manifest, null, null); + return createInstance(snapshot, id, manifest, null); } - /// Creates an instance, optionally recording non-conventional storage paths. + /// Creates an instance, optionally recording a non-conventional manifest path. /// /// @param snapshot the snapshot that will own the instance /// @param id the instance id /// @param manifest the stored instance manifest /// @param manifestFile the actual manifest JSON path, or `null` for the layout default - /// @param jarFile the actual primary jar path, or `null` for the layout default /// @return the new instance protected abstract DefaultGameInstance createInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, - @Nullable Path manifestFile, - @Nullable Path jarFile); + @Nullable Path manifestFile); } 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 49c96c904a9..dba5878c87d 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -159,9 +159,8 @@ protected TestGameInstance createInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, - @Nullable Path manifestFile, - @Nullable Path jarFile) { - return new TestGameInstance(snapshot, id, manifest, manifestFile, jarFile); + @Nullable Path manifestFile) { + return new TestGameInstance(snapshot, id, manifest, manifestFile); } /// Publishes a snapshot containing one test instance. @@ -195,14 +194,12 @@ private static final class TestGameInstance extends DefaultGameInstance { /// @param id the instance id /// @param manifest the stored manifest /// @param manifestFile non-conventional manifest path, or `null` - /// @param jarFile non-conventional jar path, or `null` private TestGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, - @Nullable Path manifestFile, - @Nullable Path jarFile) { - super(snapshot, id, manifest, manifestFile, jarFile); + @Nullable Path manifestFile) { + super(snapshot, id, manifest, manifestFile); } /// Creates a test instance that may reuse compatible session state. 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 24e36723256..0bd9c5f0fac 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java @@ -65,16 +65,14 @@ protected DefaultGameInstance createInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, - @Nullable Path manifestFile, - @Nullable Path jarFile) { + @Nullable Path manifestFile) { final class MyGameInstance extends DefaultGameInstance { MyGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, - @Nullable Path manifestFile, - @Nullable Path jarFile) { - super(snapshot, id, manifest, manifestFile, jarFile); + @Nullable Path manifestFile) { + super(snapshot, id, manifest, manifestFile); } MyGameInstance( @@ -96,7 +94,7 @@ protected DefaultGameInstance withManifest(DefaultGameRepositorySnapshot newSnap } } - return new MyGameInstance(snapshot, id, manifest, manifestFile, jarFile); + return new MyGameInstance(snapshot, id, manifest, manifestFile); } }.resolve(manifest); From 81714b35f063e8c0fcd8f3fbb05779b14169b31d Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:26:21 +0800 Subject: [PATCH 036/199] Clarify logging messages for malformed instance JSON handling --- .../java/org/jackhuang/hmcl/game/DefaultGameRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 3ea396f96e5..74f8a2e33df 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -293,7 +293,7 @@ protected void refreshImpl() { try { manifest = readInstanceManifest(json); } catch (Exception e) { - LOG.warning("Malformed version json " + id, e); + LOG.warning("Malformed instance json " + id, e); if (EventBus.EVENT_BUS.fireEvent(new GameJsonParseFailedEvent(this, json, id.id())) != Event.Result.ALLOW) { return null; } @@ -301,7 +301,7 @@ protected void refreshImpl() { try { manifest = readInstanceManifest(json); } catch (Exception e2) { - LOG.error("User corrected version json is still malformed", e2); + LOG.error("User corrected instance json is still malformed", e2); return null; } } From ed3aa3047ed1dbafadca6046da56e22ca5d30eb9 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:29:17 +0800 Subject: [PATCH 037/199] Remove unused GameJsonParseFailedEvent and simplify malformed instance JSON handling Assisted-by: grok-build:grok-4.5 --- .../hmcl/event/GameJsonParseFailedEvent.java | 64 ------------------- .../hmcl/game/DefaultGameRepository.java | 13 +--- 2 files changed, 2 insertions(+), 75 deletions(-) delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/event/GameJsonParseFailedEvent.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/GameJsonParseFailedEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/GameJsonParseFailedEvent.java deleted file mode 100644 index b589992ad8b..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/GameJsonParseFailedEvent.java +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.event; - -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.util.ToStringBuilder; - -import java.nio.file.Path; - -/** - * This event gets fired when json of a game version is malformed. You can do something here. - * auto making up for the missing json, don't forget to set result to {@link Event.Result#ALLOW}. - * and even asking for removing the redundant version folder. - * - * The result ALLOW means you have corrected the json. - */ -public final class GameJsonParseFailedEvent extends Event { - private final String version; - private final Path jsonFile; - - /** - * - * @param source {@link DefaultGameRepository} - * @param jsonFile the minecraft.json file. - * @param version the version name - */ - public GameJsonParseFailedEvent(Object source, Path jsonFile, String version) { - super(source); - this.version = version; - this.jsonFile = jsonFile; - } - - public Path getJsonFile() { - return jsonFile; - } - - public String getVersion() { - return version; - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .append("source", source) - .append("jsonFile", jsonFile) - .append("version", version) - .toString(); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 74f8a2e33df..bd4dcae5ecc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -293,17 +293,8 @@ protected void refreshImpl() { try { manifest = readInstanceManifest(json); } catch (Exception e) { - LOG.warning("Malformed instance json " + id, e); - if (EventBus.EVENT_BUS.fireEvent(new GameJsonParseFailedEvent(this, json, id.id())) != Event.Result.ALLOW) { - return null; - } - - try { - manifest = readInstanceManifest(json); - } catch (Exception e2) { - LOG.error("User corrected instance json is still malformed", e2); - return null; - } + LOG.warning("Malformed instance json " + id + " (" + json + ")", e); + return null; } // Directory name is the repository identity; keep the on-disk files untouched. From 0aecccfbf95cd1b571893781ab333be2352d8b1f Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:35:46 +0800 Subject: [PATCH 038/199] Make DefaultGameRepositorySnapshot mutators package-private Assisted-by: grok-build:grok-4.5 --- .../game/DefaultGameRepositorySnapshot.java | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) 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 9d637abb63c..ea274793f69 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -34,15 +34,18 @@ /// Default implementation of a repository index snapshot for [DefaultGameRepository]. /// -/// A snapshot begins unsealed so writers can populate it. [#seal()] freezes the instance map; -/// afterwards any mutating method throws. Callers must [#clone()] a published snapshot, edit the -/// copy, and publish it with [DefaultGameRepository#publishSnapshot(DefaultGameRepositorySnapshot)]. +/// A snapshot begins unsealed so package-private writers can populate it. [#seal()] freezes the +/// instance map; afterwards any mutating method throws. Repository write paths must [#clone()] a +/// published snapshot, edit the copy, and publish it with +/// [DefaultGameRepository#publishSnapshot(DefaultGameRepositorySnapshot)]. /// /// Once sealed, this object is exposed as a [GameRepositorySnapshot]. Provisional placeholders /// remain reachable through [#get(GameInstanceID)] but are excluded from the public snapshot view. /// -/// Subclasses such as HMCL-specific snapshots may override [#newEmpty()] to preserve concrete type -/// through [#clone()], analogous to [DefaultGameInstance#withNewSnapshot(DefaultGameRepositorySnapshot)]. +/// Mutation methods are package-private: only code in `org.jackhuang.hmcl.game` may assemble a +/// snapshot. Subclasses such as HMCL-specific snapshots may override [#newEmpty()] to preserve +/// concrete type through [#clone()], analogous to +/// [DefaultGameInstance#withNewSnapshot(DefaultGameRepositorySnapshot)]. @NotNullByDefault public class DefaultGameRepositorySnapshot implements GameRepositorySnapshot { protected final DefaultGameRepository repository; @@ -69,7 +72,7 @@ protected DefaultGameRepositorySnapshot newEmpty() { } /// Freezes this snapshot so its instance map can no longer be modified. - public void seal() { + void seal() { if (!sealed) { instances = Collections.unmodifiableMap(new TreeMap<>(instances)); sealed = true; @@ -191,7 +194,7 @@ public Map asMap() { /// Adds or replaces an instance in this unsealed snapshot. /// /// @param instance the instance bound to this snapshot - public void put(DefaultGameInstance instance) { + void put(DefaultGameInstance instance) { checkMutable(); instances.put(instance.getId(), instance); } @@ -199,7 +202,7 @@ public void put(DefaultGameInstance instance) { /// Adds or replaces all instances from the given map. /// /// @param map instances keyed by id - public void putAll(Map map) { + void putAll(Map map) { checkMutable(); instances.putAll(map); } @@ -207,13 +210,13 @@ public void putAll(Map map) { /// Removes the instance with the given id. /// /// @param id the instance id - public void remove(GameInstanceID id) { + void remove(GameInstanceID id) { checkMutable(); instances.remove(id); } /// Removes all instances from this unsealed snapshot. - public void clear() { + void clear() { checkMutable(); instances.clear(); } From 9270cb6a185e7c761928a58195f59155058e4dfa Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:39:11 +0800 Subject: [PATCH 039/199] Remove unused rename/remove instance EventBus veto hooks Assisted-by: grok-build:grok-4.5 --- .../hmcl/event/RefreshingInstancesEvent.java | 39 ----------- .../hmcl/event/RemoveInstanceEvent.java | 56 ---------------- .../hmcl/event/RenameInstanceEvent.java | 67 ------------------- .../hmcl/game/DefaultGameRepository.java | 6 +- 4 files changed, 1 insertion(+), 167 deletions(-) delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshingInstancesEvent.java delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/event/RemoveInstanceEvent.java delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/event/RenameInstanceEvent.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshingInstancesEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshingInstancesEvent.java deleted file mode 100644 index f70e8a6b84a..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshingInstancesEvent.java +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.event; - -import org.jetbrains.annotations.NotNullByDefault; - -/// This event gets fired when loading versions in a .minecraft folder. -/// -/// This event is fired on the [org.jackhuang.hmcl.event.EventBus#EVENT_BUS] -/// -/// @author huangyuhui -@NotNullByDefault -public final class RefreshingInstancesEvent extends Event { - - /// Constructor. - public RefreshingInstancesEvent(Object source) { - super(source); - } - - @Override - public boolean hasResult() { - return true; - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RemoveInstanceEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RemoveInstanceEvent.java deleted file mode 100644 index e2f8bb25f11..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RemoveInstanceEvent.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.event; - -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.util.ToStringBuilder; -import org.jetbrains.annotations.NotNullByDefault; - -/// This event gets fired when a minecraft version is being removed. -/// -/// This event is fired on the [org.jackhuang.hmcl.event.EventBus#EVENT_BUS] -/// -/// @author huangyuhui -@NotNullByDefault -public class RemoveInstanceEvent extends Event { - - private final GameInstanceID instanceId; - - /// @param instanceId the instance id. - public RemoveInstanceEvent(Object source, GameInstanceID instanceId) { - super(source); - this.instanceId = instanceId; - } - - public GameInstanceID getInstanceId() { - return instanceId; - } - - @Override - public boolean hasResult() { - return true; - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .append("source", source) - .append("instanceId", instanceId) - .toString(); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RenameInstanceEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RenameInstanceEvent.java deleted file mode 100644 index 065c7bad956..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RenameInstanceEvent.java +++ /dev/null @@ -1,67 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.event; - -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.util.ToStringBuilder; -import org.jetbrains.annotations.NotNullByDefault; - -/// This event gets fired when a minecraft instance is being removed. -/// -/// This event is fired on the [org.jackhuang.hmcl.event.EventBus#EVENT_BUS] -/// -/// @author huangyuhui -@NotNullByDefault -public final class RenameInstanceEvent extends Event { - - private final GameInstanceID from, to; - - /** - * - * @param source {@link GameRepository} - * @param from the instance id. - */ - public RenameInstanceEvent(Object source, GameInstanceID from, GameInstanceID to) { - super(source); - this.from = from; - this.to = to; - } - - public GameInstanceID getFrom() { - return from; - } - - public GameInstanceID getTo() { - return to; - } - - @Override - public boolean hasResult() { - return true; - } - - @Override - public String toString() { - return new ToStringBuilder(this) - .append("source", source) - .append("from", from) - .append("to", to) - .toString(); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index bd4dcae5ecc..fbe7b7b72ea 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -188,10 +188,6 @@ public boolean isLoaded() { @Override public void refresh() { - if (EventBus.EVENT_BUS.fireEvent(new RefreshingInstancesEvent(this)) == Event.Result.DENY) { - return; - } - refreshImpl(); loaded = true; EventBus.EVENT_BUS.fireEvent(new RefreshedGameInstancesEvent(this)); @@ -239,7 +235,7 @@ protected void refreshImpl() { loadedInstances.put(instance.getId(), instance); } } catch (NoSuchGameInstanceException e) { - LOG.warning("Ignoring instance " + instance.getId() + " because it inherits from a nonexistent version."); + LOG.warning("Ignoring instance " + instance.getId() + " because it inherits from a nonexistent instance."); } } From 137e34d6276c2f175defe29c358a2722635c1545 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:40:06 +0800 Subject: [PATCH 040/199] Refactor instance handling: update logging message and streamline instance file operations --- .../hmcl/game/DefaultGameRepository.java | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) 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 fbe7b7b72ea..7ac993da562 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -223,7 +223,7 @@ protected void refreshImpl() { } } } catch (IOException e) { - LOG.warning("Failed to load versions from " + instancesDir, e); + LOG.warning("Failed to load instance from " + instancesDir, e); } } @@ -310,9 +310,9 @@ private static GameInstanceManifest readInstanceManifest(Path json) throws IOExc } private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, GameInstanceID to) throws IOException { - Path versionsDir = baseDirectory.resolve("versions"); - Path fromDir = versionsDir.resolve(from.id()); - Path toDir = versionsDir.resolve(to.id()); + Path instancesDir = baseDirectory.resolve("versions"); + Path fromDir = instancesDir.resolve(from.id()); + Path toDir = instancesDir.resolve(to.id()); Files.move(fromDir, toDir); Path fromJson = toDir.resolve(from + ".json"); @@ -369,10 +369,6 @@ public Path getInstanceJar(GameInstanceManifest manifest) { @Override public boolean renameInstance(GameInstanceID from, GameInstanceID to) { - if (EventBus.EVENT_BUS.fireEvent(new RenameInstanceEvent(this, from, to)) == Event.Result.DENY) { - return false; - } - try { DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); DefaultGameInstance fromHolder = newSnapshot.get(from); @@ -422,10 +418,6 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { /// @return `false` if removal is denied or the instance directory cannot be staged; `true` if /// the directory is absent or staging succeeds public boolean removeInstanceFromDisk(GameInstanceID id) { - if (EventBus.EVENT_BUS.fireEvent(new RemoveInstanceEvent(this, id)) == Event.Result.DENY) { - return false; - } - if (getSnapshot().get(id) != null) { DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); newSnapshot.remove(id); From 0b0bb2772394484860a1708b5584a9781e3d5f78 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:41:32 +0800 Subject: [PATCH 041/199] Clarify logging messages for instance renaming and removal operations --- .../java/org/jackhuang/hmcl/game/DefaultGameRepository.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 7ac993da562..bef207d4c8d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -402,7 +402,7 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { publishSnapshot(newSnapshot); return true; } catch (IOException | JsonParseException | NoSuchGameInstanceException | InvalidPathException e) { - LOG.warning("Unable to rename version " + from + " to " + to, e); + LOG.warning("Unable to rename instance " + from + " to " + to, e); return false; } } @@ -434,7 +434,7 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { try { Files.move(file, removedFile, StandardCopyOption.REPLACE_EXISTING); } catch (IOException e) { - LOG.warning("Unable to remove version folder: " + file, e); + LOG.warning("Unable to remove instance directory: " + file, e); return false; } @@ -453,7 +453,7 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { try { FileUtils.deleteDirectory(removedFile); } catch (IOException e) { - LOG.warning("Unable to remove version folder: " + file, e); + LOG.warning("Unable to remove instance directory: " + removedFile, e); } return true; } finally { From 305a4754277ac543aa99d51f7136c041e9517d15 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:45:53 +0800 Subject: [PATCH 042/199] Rename method for clarity and enhance error handling during file operations --- .../hmcl/game/DefaultGameRepository.java | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) 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 bef207d4c8d..59f352bead8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -88,7 +88,7 @@ private static Library classicLibrary(String name) { null, null, null, null, null, null); } - private static boolean hasClassicVersion(Path baseDirectory) { + private static boolean hasClassicInstance(Path baseDirectory) { Path bin = baseDirectory.resolve("bin"); return Files.isDirectory(bin) && Files.exists(bin.resolve("lwjgl.jar")) @@ -197,7 +197,7 @@ protected void refreshImpl() { DefaultGameRepositorySnapshot newSnapshot = createSnapshot(getSnapshot().getLayout()); DefaultGameRepositoryLayout layout = newSnapshot.getLayout(); - if (hasClassicVersion(layout.getBaseDirectory())) { + if (hasClassicInstance(layout.getBaseDirectory())) { GameInstanceID id = CLASSIC_MANIFEST.id(); newSnapshot.put(createInstance(newSnapshot, id, CLASSIC_MANIFEST)); } @@ -328,11 +328,25 @@ private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, G Files.move(fromJar, toJar); } } catch (IOException e) { - Lang.ignoringException(() -> Files.move(toJson, fromJson)); + try { + Files.move(toJson, fromJson); + } catch (Throwable e2) { + e.addSuppressed(e2); + } + if (hasJarFile) { - Lang.ignoringException(() -> Files.move(toJar, fromJar)); + try { + Files.move(toJar, fromJar); + } catch (Throwable e2) { + e.addSuppressed(e2); + } + } + + try { + Files.move(toDir, fromDir); + } catch (Exception e2) { + e.addSuppressed(e2); } - Lang.ignoringException(() -> Files.move(toDir, fromDir)); throw e; } } From ba8cc09e72ce1cb07f5b85e810dfd5631840ed69 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 20:57:14 +0800 Subject: [PATCH 043/199] Refactor game instance handling: remove GameInstanceLoadable interface and update related classes to use instance context directly --- .../hmcl/ui/download/DownloadPage.java | 4 +- .../hmcl/ui/game/GameSettingsPage.java | 18 +++- .../hmcl/ui/instances/DownloadListPage.java | 3 +- .../hmcl/ui/instances/GameInstancePage.java | 92 +++++++------------ .../hmcl/ui/instances/InstallerListPage.java | 18 +++- .../hmcl/ui/instances/ModListPage.java | 19 +++- .../ui/instances/ResourcePackListPage.java | 19 +++- .../hmcl/ui/instances/SchematicsPage.java | 18 +++- .../hmcl/ui/instances/WorldListPage.java | 18 +++- .../hmcl/ui/main/LauncherSettingsPage.java | 2 +- 10 files changed, 131 insertions(+), 80 deletions(-) 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 13499c15afb..4dbf0408e15 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 @@ -136,8 +136,8 @@ public DownloadPage(GameInstanceID uploadInstance) { private static Supplier loadVersionFor(Supplier nodeSupplier) { return () -> { T node = nodeSupplier.get(); - if (node instanceof GameInstancePage.GameInstanceLoadable loadable) { - loadable.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())); + if (node instanceof DownloadListPage page) { + page.loadInstance(HMCLGameInstance.Optional.empty(GameDirectoryManager.getSelectedRepository())); } return node; }; 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 84b07baca81..c985db1cb2e 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 @@ -29,6 +29,7 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.value.ChangeListener; +import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.css.PseudoClass; @@ -78,7 +79,7 @@ /// @author Glavo @NotNullByDefault public final class GameSettingsPage extends StackPane - implements DecoratorPage, GameInstancePage.GameInstanceLoadable, PageAware { + implements DecoratorPage, PageAware { private static final Object INHERIT_BUTTON_TOOLTIP_KEY = new Object(); private static final PseudoClass PSEUDO_OVERRIDDEN = PseudoClass.getPseudoClass("overridden"); @@ -124,12 +125,24 @@ public final class GameSettingsPage extends StackPane private final InvalidationListener javaListener = o -> refreshJavaSettings(); private final InvalidationListener weakJavaListener = holder.weak(javaListener); - public GameSettingsPage(Class settingType) { + /// Creates a settings page. + /// + /// @param settingType [GameSettings.Instance] or [GameSettings.Preset] + /// @param instanceContext parent instance property for instance settings; ignored for presets and may be `null` + public GameSettingsPage( + Class settingType, + @Nullable ObservableValue instanceContext) { assert settingType == GameSettings.Preset.class || settingType == GameSettings.Instance.class; this.isPresetSetting = settingType == GameSettings.Preset.class; if (!isPresetSetting) { bindActiveParentSetting(); + Objects.requireNonNull(instanceContext, "instanceContext"); + holder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } this.scrollPane = new ScrollPane(); @@ -2632,7 +2645,6 @@ public ReadOnlyObjectProperty stateProperty() { } @SuppressWarnings("unchecked") - @Override public void loadInstance(HMCLGameInstance.Optional instance) { HMCLGameInstance gameInstance = instance.instance(); this.gameInstance.set(gameInstance); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java index 226aa11ff25..68ee9fa72be 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java @@ -69,7 +69,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.javafx.ExtendedProperties.selectedItemPropertyFor; -public class DownloadListPage extends Control implements DecoratorPage, GameInstancePage.GameInstanceLoadable { +public class DownloadListPage extends Control implements DecoratorPage { protected final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); private final BooleanProperty loading = new SimpleBooleanProperty(false); private final BooleanProperty failed = new SimpleBooleanProperty(false); @@ -112,7 +112,6 @@ public ObservableList getActions() { return actions; } - @Override public void loadInstance(HMCLGameInstance.Optional instance) { this.instanceReference.set(instance); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index 37c44d861c9..0127973df15 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -49,8 +49,6 @@ import org.jetbrains.annotations.Nullable; import java.nio.file.Path; -import java.util.Optional; -import java.util.function.Supplier; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -66,7 +64,8 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentInstanceUpgradable = new SimpleBooleanProperty(); - private final ObjectProperty instance = new SimpleObjectProperty<>(); + private final ObjectProperty instance = + new SimpleObjectProperty<>(this, "instance"); private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private GameInstanceID preferredInstanceId = null; @@ -80,12 +79,13 @@ public WorkingDirChangedEvent() { } public GameInstancePage() { - gameSettingsTab.setNodeSupplier(loadInstanceFor(() -> new GameSettingsPage<>(GameSettings.Instance.class))); - installerListTab.setNodeSupplier(loadInstanceFor(InstallerListPage::new)); - modListTab.setNodeSupplier(loadInstanceFor(ModListPage::new)); - resourcePackTab.setNodeSupplier(loadInstanceFor(ResourcePackListPage::new)); - worldListTab.setNodeSupplier(loadInstanceFor(WorldListPage::new)); - schematicsTab.setNodeSupplier(loadInstanceFor(SchematicsPage::new)); + // Child tabs subscribe to instanceProperty() themselves and reload on change. + gameSettingsTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Instance.class, instance)); + installerListTab.setNodeSupplier(() -> new InstallerListPage(instance)); + modListTab.setNodeSupplier(() -> new ModListPage(instance)); + resourcePackTab.setNodeSupplier(() -> new ResourcePackListPage(instance)); + worldListTab.setNodeSupplier(() -> new WorldListPage(instance)); + schematicsTab.setNodeSupplier(() -> new SchematicsPage(instance)); tab = new TabHeader(transitionPane, gameSettingsTab, installerListTab, modListTab, resourcePackTab, worldListTab, schematicsTab); tab.select(gameSettingsTab); @@ -95,22 +95,35 @@ public GameInstancePage() { addEventHandler(WorkingDirChangedEvent.EVENT_TYPE, event -> { HMCLGameInstance.Optional current = this.instance.get(); if (current != null) { - current = current.refreshed(); - this.instance.set(current); - if (installerListTab.isInitialized()) - installerListTab.getNode().loadInstance(current); - if (modListTab.isInitialized()) - modListTab.getNode().loadInstance(current); - if (resourcePackTab.isInitialized()) - resourcePackTab.getNode().loadInstance(current); - if (worldListTab.isInitialized()) - worldListTab.getNode().loadInstance(current); - if (schematicsTab.isInitialized()) - schematicsTab.getNode().loadInstance(current); + // Re-resolve so subscribed tabs reload from the current snapshot. + this.instance.set(current.refreshed()); } }); listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> checkSelectedInstance(), EventPriority.HIGHEST)); + + // Page chrome that depends on the current instance. + listenerHolder.add(FXUtils.onWeakChange(instance, current -> { + if (current == null) { + return; + } + HMCLGameInstance gameInstance = current.instance(); + currentInstanceUpgradable.set( + gameInstance != null && current.repository().isModpack(gameInstance.getId())); + if (gameInstance != null) { + preferredInstanceId = gameInstance.getId(); + } + })); + } + + /// Returns the current instance context for this page and its tabs. + /// + /// Child tabs subscribe to this property and reload when it changes. The page only publishes + /// context; it does not push `loadInstance` into children. + /// + /// @return the observable instance context + public ReadOnlyObjectProperty instanceProperty() { + return instance; } private void checkSelectedInstance() { @@ -129,17 +142,6 @@ private void checkSelectedInstance() { }); } - private Supplier loadInstanceFor(Supplier nodeSupplier) { - return () -> { - T node = nodeSupplier.get(); - HMCLGameInstance.Optional current = instance.get(); - if (current != null && node instanceof GameInstancePage.GameInstanceLoadable loadable) { - loadable.loadInstance(current); - } - return node; - }; - } - public void showInstanceSettings() { tab.select(gameSettingsTab, false); } @@ -157,24 +159,7 @@ public void loadInstance(GameInstanceID instanceId, HMCLGameRepository repositor return; } - HMCLGameInstance.Optional current = HMCLGameInstance.Optional.of(repository, instanceId); - this.instance.set(current); - preferredInstanceId = instanceId; - - if (gameSettingsTab.isInitialized()) - gameSettingsTab.getNode().loadInstance(current); - if (installerListTab.isInitialized()) - installerListTab.getNode().loadInstance(current); - if (modListTab.isInitialized()) - modListTab.getNode().loadInstance(current); - if (resourcePackTab.isInitialized()) - resourcePackTab.getNode().loadInstance(current); - if (worldListTab.isInitialized()) - worldListTab.getNode().loadInstance(current); - if (schematicsTab.isInitialized()) - schematicsTab.getNode().loadInstance(current); - HMCLGameInstance gameInstance = current.instance(); - currentInstanceUpgradable.set(gameInstance != null && repository.isModpack(gameInstance.getId())); + this.instance.set(HMCLGameInstance.Optional.of(repository, instanceId)); } private void onNavigated(Navigator.NavigationEvent event) { @@ -403,11 +388,4 @@ protected Skin(GameInstancePage control) { } } - /// Loads page content for a game instance in a repository. - public interface GameInstanceLoadable { - /// Loads page content for the given optional game instance. - /// - /// @param instance the instance context; may be empty when only repository context is available - void loadInstance(HMCLGameInstance.Optional instance); - } } 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 207eaf52879..6b3c759685a 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 @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.ui.instances; import javafx.application.Platform; +import javafx.beans.value.ObservableValue; import javafx.scene.Node; import javafx.scene.control.Skin; import javafx.stage.FileChooser; @@ -39,21 +40,33 @@ import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -public class InstallerListPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable { +public class InstallerListPage extends ListPageBase { + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private @Nullable HMCLGameInstance gameInstance; private GameInstanceManifest manifest; private String gameVersion; - { + /// Creates an installer list that reloads when `instanceContext` changes. + /// + /// @param instanceContext the parent page's instance property + public InstallerListPage(ObservableValue instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, it -> Arrays.asList("jar", "exe").contains(FileUtils.getExtension(it)), mods -> { if (!mods.isEmpty()) doInstallOffline(mods.get(0)); }); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } @Override @@ -61,7 +74,6 @@ protected Skin createDefaultSkin() { return new InstallerListPageSkin(); } - @Override public void loadInstance(HMCLGameInstance.Optional instance) { this.gameInstance = instance.instance(); if (gameInstance == null) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index 8a17626dbbe..011ca94796f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.ui.instances; +import javafx.beans.value.ObservableValue; import javafx.collections.ObservableList; import javafx.scene.control.Skin; import javafx.stage.FileChooser; @@ -35,6 +36,7 @@ import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.ListPageBase; +import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.ui.construct.MessageDialogPane; import org.jackhuang.hmcl.ui.construct.PageAware; import org.jackhuang.hmcl.util.TaskCancellationAction; @@ -45,6 +47,7 @@ import java.io.UncheckedIOException; import java.nio.file.Path; import java.util.*; +import java.util.Objects; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; import java.util.concurrent.locks.ReentrantLock; @@ -52,8 +55,9 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class ModListPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable, PageAware { +public final class ModListPage extends ListPageBase implements PageAware { private final ReentrantLock lock = new ReentrantLock(); + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private ModManager modManager; private @Nullable HMCLGameInstance gameInstance; @@ -61,7 +65,11 @@ public final class ModListPage extends ListPageBase supportedLoaders = EnumSet.noneOf(ModLoaderType.class); - public ModListPage() { + /// Creates a mod list that reloads when `instanceContext` changes. + /// + /// @param instanceContext the parent page's instance property + public ModListPage(ObservableValue instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, it -> ModManager.MOD_EXTENSIONS.contains(FileUtils.getExtension(it).toLowerCase(Locale.ROOT)), mods -> { mods.forEach(it -> { try { @@ -72,6 +80,12 @@ public ModListPage() { }); loadMods(modManager); }); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } @Override @@ -83,7 +97,6 @@ public void refresh() { loadMods(modManager); } - @Override public void loadInstance(HMCLGameInstance.Optional instance) { this.gameInstance = instance.instance(); if (gameInstance == null) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java index acac9f27c6b..980272dd448 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java @@ -23,6 +23,7 @@ import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.value.ObservableValue; import javafx.css.PseudoClass; import javafx.geometry.Insets; import javafx.geometry.Pos; @@ -53,6 +54,7 @@ import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.ListPageBase; import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.ui.animation.ContainerAnimations; import org.jackhuang.hmcl.ui.animation.TransitionPane; import org.jackhuang.hmcl.ui.construct.*; @@ -65,6 +67,7 @@ import java.io.IOException; import java.nio.file.Path; import java.util.*; +import java.util.Objects; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; import java.util.stream.Stream; @@ -76,7 +79,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class ResourcePackListPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable { +public final class ResourcePackListPage extends ListPageBase { private static final String TIP_KEY = "resourcePackWarning"; private static @Nullable String getWarning(ResourcePackFile.Compatibility compatibility) { @@ -90,6 +93,7 @@ public final class ResourcePackListPage extends ListPageBase instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, ResourcePackFile::isFileResourcePack, this::addFiles); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } @Override @@ -106,7 +120,6 @@ protected Skin createDefaultSkin() { return new ResourcePackListPageSkin(this); } - @Override public void loadInstance(HMCLGameInstance.Optional instance) { this.gameInstance = instance.instance(); if (gameInstance == null) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java index ac27b1915db..0a315136e36 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/SchematicsPage.java @@ -20,6 +20,7 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; import com.jfoenix.controls.JFXListView; +import javafx.beans.value.ObservableValue; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; @@ -53,6 +54,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.util.*; +import java.util.Objects; import java.util.stream.Stream; import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; @@ -62,7 +64,7 @@ /** * @author Glavo */ -public final class SchematicsPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable { +public final class SchematicsPage extends ListPageBase { private static String translateAuthorName(String author) { if (I18n.isUseChinese() && "hsds".equals(author)) { @@ -71,14 +73,25 @@ private static String translateAuthorName(String author) { return author; } + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private Path schematicsDirectory; private DirItem currentDirectory; - public SchematicsPage() { + /// Creates a schematics list that reloads when `instanceContext` changes. + /// + /// @param instanceContext the parent page's instance property + public SchematicsPage(ObservableValue instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, file -> currentDirectory != null && Files.isRegularFile(file) && FileUtils.getName(file).endsWith(".litematic"), this::addFiles ); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } @Override @@ -86,7 +99,6 @@ protected Skin createDefaultSkin() { return new SchematicsPageSkin(); } - @Override public void loadInstance(HMCLGameInstance.Optional instance) { HMCLGameInstance gameInstance = instance.instance(); this.schematicsDirectory = gameInstance != null ? gameInstance.getSchematicsDirectory() : null; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java index 80c4aa03abe..b77c0a00a62 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldListPage.java @@ -24,6 +24,7 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.ReadOnlyBooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.value.ObservableValue; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; @@ -55,6 +56,7 @@ import java.time.Instant; import java.util.Arrays; import java.util.List; +import java.util.Objects; import static org.jackhuang.hmcl.ui.FXUtils.determineOptimalPopupPosition; import static org.jackhuang.hmcl.util.StringUtils.parseColorEscapes; @@ -62,8 +64,9 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class WorldListPage extends ListPageBase implements GameInstancePage.GameInstanceLoadable { +public final class WorldListPage extends ListPageBase { private final BooleanProperty showAll = new SimpleBooleanProperty(this, "showAll", false); + private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private Path savesDir; private List worlds; @@ -72,12 +75,22 @@ public final class WorldListPage extends ListPageBase implements GameInst private int refreshCount = 0; - public WorldListPage() { + /// Creates a world list that reloads when `instanceContext` changes. + /// + /// @param instanceContext the parent page's instance property + public WorldListPage(ObservableValue instanceContext) { + Objects.requireNonNull(instanceContext, "instanceContext"); FXUtils.applyDragListener(this, it -> "zip".equals(FileUtils.getExtension(it)), modpacks -> { installWorld(modpacks.get(0)); }); showAll.addListener(e -> updateWorldList()); + + listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { + if (current != null) { + loadInstance(current); + } + })); } @Override @@ -85,7 +98,6 @@ protected Skin createDefaultSkin() { return new WorldListPageSkin(); } - @Override public void loadInstance(HMCLGameInstance.Optional instance) { this.gameInstance = instance.instance(); this.savesDir = gameInstance != null ? gameInstance.getSavesDirectory() : null; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java index 544471ba756..9493dd9fcec 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/LauncherSettingsPage.java @@ -49,7 +49,7 @@ public class LauncherSettingsPage extends DecoratorAnimatedPage implements Decor private final TransitionPane transitionPane = new TransitionPane(); public LauncherSettingsPage() { - gameTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Preset.class)); + gameTab.setNodeSupplier(() -> new GameSettingsPage<>(GameSettings.Preset.class, null)); javaManagementTab.setNodeSupplier(JavaManagementPage::new); settingsTab.setNodeSupplier(SettingsPage::new); personalizationTab.setNodeSupplier(PersonalizationPage::new); From 1ed6ff0af4f72cd9fdcbd0bd103985fcfca8af9d Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 21:07:41 +0800 Subject: [PATCH 044/199] Refactor game instance resolution: replace method call to use getResolvedManifest for clarity --- .../java/org/jackhuang/hmcl/game/DefaultGameRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 59f352bead8..8c654728b59 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -230,7 +230,7 @@ protected void refreshImpl() { Map loadedInstances = new TreeMap<>(); for (DefaultGameInstance instance : newSnapshot.values()) { try { - GameInstanceManifest resolved = newSnapshot.resolve(instance.getManifest()).launchManifest(); + GameInstanceManifest resolved = instance.getResolvedManifest().launchManifest(); if (CompatibilityRule.appliesToCurrentEnvironment(resolved.compatibilityRules())) { loadedInstances.put(instance.getId(), instance); } From 060f9374c12cf9fa25d1879117e92400854387a4 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 21:18:21 +0800 Subject: [PATCH 045/199] Drop redundant repository and id fields from ModpackUpdateTask Assisted-by: grok-build:grok-4.5 --- .../hmcl/modpack/ModpackUpdateTask.java | 33 +++++++------------ 1 file changed, 12 insertions(+), 21 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java index 7638a7697a3..7d0c9c09f6b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackUpdateTask.java @@ -18,8 +18,6 @@ package org.jackhuang.hmcl.modpack; import org.jackhuang.hmcl.game.DefaultGameInstance; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.NotNullByDefault; @@ -37,12 +35,6 @@ public class ModpackUpdateTask extends Task { /// The fixed pre-update instance snapshot. private final DefaultGameInstance instance; - /// The repository that owns [#instance]. - private final DefaultGameRepository repository; - - /// The ID of [#instance]. - private final GameInstanceID id; - /// The task that applies the modpack update after the backup is created. private final Task updateTask; @@ -55,15 +47,14 @@ public class ModpackUpdateTask extends Task { /// @param updateTask the task that performs the update public ModpackUpdateTask(DefaultGameInstance instance, Task updateTask) { this.instance = instance; - this.repository = instance.getRepository(); - this.id = instance.getId(); this.updateTask = updateTask; Path backup = instance.getLayout().getBaseDirectory().resolve("backup"); while (true) { - int num = (int)(Math.random() * 10000000); - if (!Files.exists(backup.resolve(id + "-" + num))) { - backupFolder = backup.resolve(id + "-" + num); + int num = (int) (Math.random() * 10000000); + Path candidate = backup.resolve(instance.getId() + "-" + num); + if (!Files.exists(candidate)) { + backupFolder = candidate; break; } } @@ -96,15 +87,15 @@ public boolean doPostExecute() { public void postExecute() throws Exception { if (isDependenciesSucceeded()) { // Keep backup game version for further repair. - } else { - // Restore backup - if (!repository.removeInstanceFromDisk(id)) { - throw new IOException("Failed to remove instance before restoring backup: " + id); - } - - FileUtils.copyDirectory(backupFolder, instance.getInstanceRoot()); + return; + } - repository.refresh(); + // Restore backup + if (!instance.getRepository().removeInstanceFromDisk(instance.getId())) { + throw new IOException("Failed to remove instance before restoring backup: " + instance.getId()); } + + FileUtils.copyDirectory(backupFolder, instance.getInstanceRoot()); + instance.getRepository().refresh(); } } From 72497de61566a430f360d67bc6d7a40d1e5addbe Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 21:39:51 +0800 Subject: [PATCH 046/199] Clarify documentation for DefaultGameInstance and its snapshot behavior; ensure addon managers are not shared across snapshots --- .../hmcl/game/DefaultGameInstance.java | 37 ++++++++++--------- .../jackhuang/hmcl/game/GameRepository.java | 24 ------------ .../hmcl/game/DefaultGameInstanceTest.java | 12 +++--- 3 files changed, 25 insertions(+), 48 deletions(-) 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 254d6db860e..174beefd82f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -34,10 +34,10 @@ /// Default snapshot member for an official-layout game instance. /// /// Index fields (`id`, `manifest`, layout binding, and optional non-conventional file paths) belong -/// to a [DefaultGameRepositorySnapshot]. Session services such as [#getModManager()] and -/// [#getResourcePackManager()] are lazy and are shared across copies only while the instance ID -/// and stored manifest remain unchanged, so ordinary COW publishes preserve caches without leaking -/// manifest-derived state into an updated instance. +/// to a [DefaultGameRepositorySnapshot]. Lazy services such as [#getModManager()] and +/// [#getResourcePackManager()] belong to this snapshot member only: copies produced by +/// [#withNewSnapshot] / [#withManifest] do not inherit them, so a repository refresh or COW publish +/// does not keep a long-lived addon-manager session. @NotNullByDefault public abstract class DefaultGameInstance implements GameInstance { @@ -61,10 +61,10 @@ public abstract class DefaultGameInstance implements GameInstance { /// stored as [GameVersionNumber#unknown()] rather than left null. protected @Nullable GameVersionNumber version; - /// Lazily created mod manager shared across snapshot wrappers for this instance id. + /// Lazily created mod manager for this snapshot member only. private @Nullable ModManager modManager; - /// Lazily created resource-pack manager shared across snapshot wrappers for this instance id. + /// Lazily created resource-pack manager for this snapshot member only. private @Nullable ResourcePackManager resourcePackManager; protected DefaultGameInstance( @@ -93,15 +93,16 @@ protected DefaultGameInstance( this.manifestFile = manifestFile; } - /// Creates an instance that may reuse session state and storage paths from another snapshot wrapper. + /// Creates an instance that may reuse storage paths and version cache from another snapshot wrapper. /// - /// The manifest path is copied when `id` equals that of `shareSession`. Cached version and manager - /// state is copied only when `id` and `manifest` also equal those of `shareSession`. + /// The manifest path is copied when `id` equals that of `shareSession`. The cached game version is + /// copied only when `id` and `manifest` also equal those of `shareSession`. Addon managers are + /// never shared: each snapshot member creates its own managers on first use. /// /// @param snapshot the snapshot that will own the copy /// @param id the instance id /// @param manifest the stored instance manifest - /// @param shareSession the instance whose session services and caches should be shared + /// @param shareSession the instance whose stable path/version state may be reused protected DefaultGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, @@ -114,8 +115,6 @@ protected DefaultGameInstance( Objects.equals(id, shareSession.id) ? shareSession.manifestFile : null); if (Objects.equals(id, shareSession.id) && Objects.equals(manifest, shareSession.manifest)) { this.version = shareSession.version; - this.modManager = shareSession.modManager; - this.resourcePackManager = shareSession.resourcePackManager; } } @@ -180,10 +179,11 @@ public GameVersionNumber getVersion() { return version; } - /// Returns the mod manager for this instance. + /// Returns the mod manager for this snapshot member. /// - /// The manager is created on first use and shared across snapshot wrappers whose instance ID - /// and stored manifest remain unchanged. + /// The manager is created on first use and is not shared with other snapshot wrappers. After a + /// repository refresh or COW publish, callers should obtain the manager from the current + /// instance again. /// /// @return the mod manager public ModManager getModManager() { @@ -193,10 +193,11 @@ public ModManager getModManager() { return modManager; } - /// Returns the resource-pack manager for this instance. + /// Returns the resource-pack manager for this snapshot member. /// - /// The manager is created on first use and shared across snapshot wrappers whose instance ID - /// and stored manifest remain unchanged. + /// The manager is created on first use and is not shared with other snapshot wrappers. After a + /// repository refresh or COW publish, callers should obtain the manager from the current + /// instance again. /// /// @return the resource-pack manager public ResourcePackManager getResourcePackManager() { 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 08d85d08c61..8471895bcbd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -168,30 +168,6 @@ default Path getResourcePackDirectory(GameInstanceID instanceId) { return getRunDirectory(instanceId).resolve("resourcepacks"); } - /// Returns the saves directory for an instance. - /// - /// @param instanceId the instance id - /// @return the saves directory below the run directory - default Path getSavesDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("saves"); - } - - /// Returns the world backups directory for an instance. - /// - /// @param instanceId the instance id - /// @return the backups directory below the run directory - default Path getBackupsDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("backups"); - } - - /// Returns the schematics directory for an instance. - /// - /// @param instanceId the instance id - /// @return the schematics directory below the run directory - default Path getSchematicsDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("schematics"); - } - /// Returns the primary client jar path for a manifest. /// /// @param manifest the manifest whose jar should be located 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 dba5878c87d..99006fcc431 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -52,9 +52,9 @@ public void testPrimaryJarUsesResolvedJarField(@TempDir Path tempDirectory) { assertEquals(repository.getLayout().getInstanceJarFile(jarId), instance.getInstanceJarFile()); } - /// Manifest changes do not reuse version or manager caches from the previous snapshot member. + /// Snapshot copies never reuse addon managers; only the version cache is shared for the same manifest. @Test - public void testManifestChangeInvalidatesDerivedState(@TempDir Path tempDirectory) throws IOException { + public void testSnapshotCopyDoesNotShareAddonManagers(@TempDir Path tempDirectory) throws IOException { TestRepository repository = new TestRepository(tempDirectory); GameInstanceID instanceId = new GameInstanceID("instance"); GameInstanceID oldJarId = new GameInstanceID("old-jar"); @@ -68,10 +68,10 @@ public void testManifestChangeInvalidatesDerivedState(@TempDir Path tempDirector var originalModManager = original.getModManager(); var originalResourcePackManager = original.getResourcePackManager(); - TestGameInstance unchanged = original.withNewSnapshot(repository.newSnapshot()); - assertSame(original.cachedVersion(), unchanged.cachedVersion()); - assertSame(originalModManager, unchanged.getModManager()); - assertSame(originalResourcePackManager, unchanged.getResourcePackManager()); + TestGameInstance sameManifestCopy = original.withNewSnapshot(repository.newSnapshot()); + assertSame(original.cachedVersion(), sameManifestCopy.cachedVersion()); + assertNotSame(originalModManager, sameManifestCopy.getModManager()); + assertNotSame(originalResourcePackManager, sameManifestCopy.getResourcePackManager()); GameInstanceManifest newManifest = oldManifest.withJar(newJarId); TestGameInstance updated = original.withManifest(repository.newSnapshot(), newManifest); From 7c993c5c09f36e0f147ac373101608c88a3d164b Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 21:44:25 +0800 Subject: [PATCH 047/199] Bind LocalAddonManager to DefaultGameInstance instead of repository and id Assisted-by: grok-build:grok-4.5 --- .../hmcl/addon/LocalAddonManager.java | 63 +++++++++++++++---- .../jackhuang/hmcl/addon/mod/ModManager.java | 19 +++--- .../resourcepack/ResourcePackManager.java | 18 +++--- .../download/forge/ForgeNewInstallTask.java | 2 +- .../hmcl/game/DefaultGameInstance.java | 4 +- 5 files changed, 71 insertions(+), 35 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java index 0946df626fb..8a81e1fe356 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java @@ -17,8 +17,7 @@ */ package org.jackhuang.hmcl.addon; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameRepository; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.NotNull; @@ -34,41 +33,70 @@ import java.util.Set; import java.util.concurrent.locks.ReentrantLock; +/// Manages local addon files for a single [DefaultGameInstance] snapshot member. +/// +/// Each manager is bound to one instance wrapper and must not be shared across repository snapshot +/// copies. Callers obtain a manager from the current instance after a refresh or COW publish. +/// +/// @param the local addon file type managed by this manager public abstract class LocalAddonManager { + /// File-name suffix used for disabled addon files. public static final String DISABLED_EXTENSION = ".disabled"; + + /// File-name suffix used for backed-up (old) addon files. public static final String OLD_EXTENSION = ".old"; + /// Returns the display file name of an addon path with disable/old suffixes stripped. + /// + /// @param file the addon file path + /// @return the file name without [#DISABLED_EXTENSION] or [#OLD_EXTENSION] public static String getLocalAddonName(Path file) { return StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION); } + /// Lock guarding [#localFiles] and subclass mutable state. protected final ReentrantLock lock = new ReentrantLock(); + /// Loaded local addon files for the bound instance. protected final Set<@NotNull T> localFiles = new LinkedHashSet<>(); - protected final GameRepository repository; - protected final GameInstanceID instanceId; - - public LocalAddonManager(GameRepository gameRepository, GameInstanceID instanceId) { - this.repository = gameRepository; - this.instanceId = instanceId; - } + /// The snapshot member this manager serves. + protected final DefaultGameInstance instance; - public GameRepository getRepository() { - return repository; + /// Creates a manager bound to the given instance. + /// + /// @param instance the snapshot member whose addon directory this manager operates on + public LocalAddonManager(DefaultGameInstance instance) { + this.instance = instance; } - public GameInstanceID getInstanceId() { - return instanceId; + /// Returns the instance this manager is bound to. + /// + /// @return the bound [DefaultGameInstance] + public DefaultGameInstance getInstance() { + return instance; } + /// Returns the directory that stores local addon files for the bound instance. + /// + /// @return the addon directory path public abstract Path getDirectory(); + /// Reloads local addon files from disk into [#localFiles]. + /// + /// @throws IOException if the directory cannot be listed or a required instance path cannot be read public abstract void refresh() throws IOException; + /// Returns the comparator used to order [#getLocalFiles()]. + /// + /// @return the sort order for local addon files public abstract Comparator getComparator(); + /// Returns the currently loaded local addon files, sorted by [#getComparator()]. + /// + /// @return an unmodifiable sorted list of local addon files + /// @throws IOException if loading is required and fails public @Unmodifiable List getLocalFiles() throws IOException { lock.lock(); try { @@ -78,6 +106,15 @@ public GameInstanceID getInstanceId() { } } + /// Marks an addon file as old (backed up) or restores it from the old location. + /// + /// When `old` is `true`, the file is renamed with [#OLD_EXTENSION] and removed from + /// [#localFiles]. When `old` is `false`, the suffix is removed and the file is re-added. + /// + /// @param modFile the local addon file to update + /// @param old whether the file should be treated as a backup + /// @return the path after the rename + /// @throws IOException if the file cannot be moved public Path setOld(T modFile, boolean old) throws IOException { lock.lock(); try { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java index ab60fac8b29..3dea12cd887 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java @@ -22,9 +22,7 @@ import org.jackhuang.hmcl.addon.LocalAddonManager; import org.jackhuang.hmcl.addon.meta.*; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.game.NoSuchGameInstanceException; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; @@ -70,13 +68,16 @@ private interface ModMetadataReader { private boolean loaded = false; - public ModManager(GameRepository repository, GameInstanceID id) { - super(repository, id); + /// Creates a mod manager for the given instance. + /// + /// @param instance the snapshot member whose mods directory this manager operates on + public ModManager(DefaultGameInstance instance) { + super(instance); } @Override public Path getDirectory() { - return repository.getModsDirectory(instanceId); + return instance.getModsDirectory(); } public LibraryAnalyzer getLibraryAnalyzer() { @@ -180,11 +181,7 @@ public void refresh() throws IOException { localFiles.clear(); localMods.clear(); - try { - analyzer = LibraryAnalyzer.analyze(getRepository().getResolvedInstanceManifest(instanceId), null); - } catch (NoSuchGameInstanceException e) { - throw new IOException(e); - } + analyzer = LibraryAnalyzer.analyze(instance.getResolvedManifest(), null); boolean supportSubfolders = analyzer.has(LibraryAnalyzer.LibraryType.FORGE) || analyzer.has(LibraryAnalyzer.LibraryType.QUILT); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java index aee0e34e31e..cb7a39e99c2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/resourcepack/ResourcePackManager.java @@ -19,9 +19,8 @@ import com.google.gson.annotations.SerializedName; import kala.encdet.EncodingDetector; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameRepository; import org.jackhuang.hmcl.addon.LocalAddonManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.meta.PackMcMeta; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; @@ -222,10 +221,13 @@ private static List deserializePackList(String json) { private boolean loaded = false; - public ResourcePackManager(GameRepository repository, GameInstanceID instanceId) { - super(repository, instanceId); - this.resourcePackDirectory = this.repository.getResourcePackDirectory(this.instanceId); - this.optionsFile = repository.getRunDirectory(instanceId).resolve("options.txt"); + /// Creates a resource-pack manager for the given instance. + /// + /// @param instance the snapshot member whose resource packs this manager operates on + public ResourcePackManager(DefaultGameInstance instance) { + super(instance); + this.resourcePackDirectory = instance.getResourcePackDirectory(); + this.optionsFile = instance.getRunDirectory().resolve("options.txt"); } private @Nullable Charset optionsFileEncoding; @@ -279,7 +281,7 @@ public GameVersionNumber getMinecraftVersion() { lock.lock(); try { if (minecraftVersion == null) { - minecraftVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(instanceId)); + minecraftVersion = instance.getVersion(); supportsNewOptionsFormat = isMcVersionSupportsNewOptionsFormat(minecraftVersion); } } finally { @@ -295,7 +297,7 @@ public PackMcMeta.PackVersion getRequiredVersion() { lock.lock(); try { if (requiredVersion == null) - requiredVersion = getPackVersion(getMinecraftVersion(), repository.getInstanceJar(instanceId)); + requiredVersion = getPackVersion(getMinecraftVersion(), instance.getInstanceJarFile()); } finally { lock.unlock(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java index f14de30af3f..cd880150df4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java @@ -200,7 +200,7 @@ public void execute() throws Exception { private final String selfVersion; private Path tempDir; - private AtomicInteger processorDoneCount = new AtomicInteger(0); + private final AtomicInteger processorDoneCount = new AtomicInteger(0); public ForgeNewInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, String selfVersion, Path installer) { this.dependencyManager = dependencyManager; 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 174beefd82f..df8839e8e33 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -188,7 +188,7 @@ public GameVersionNumber getVersion() { /// @return the mod manager public ModManager getModManager() { if (modManager == null) { - modManager = new ModManager(repository, id); + modManager = new ModManager(this); } return modManager; } @@ -202,7 +202,7 @@ public ModManager getModManager() { /// @return the resource-pack manager public ResourcePackManager getResourcePackManager() { if (resourcePackManager == null) { - resourcePackManager = new ResourcePackManager(repository, id); + resourcePackManager = new ResourcePackManager(this); } return resourcePackManager; } From 925e7ea470d44d188dfb14b629d35de9330a867e Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 21:52:16 +0800 Subject: [PATCH 048/199] Remove redundant constructors from Launcher classes --- .../java/org/jackhuang/hmcl/game/HMCLGameLauncher.java | 4 ---- .../java/org/jackhuang/hmcl/launch/DefaultLauncher.java | 8 -------- .../src/main/java/org/jackhuang/hmcl/launch/Launcher.java | 8 -------- 3 files changed, 20 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index 8dd12d8432e..b09fd4f0eee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -42,10 +42,6 @@ */ public final class HMCLGameLauncher extends DefaultLauncher { - public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options) { - this(repository, manifest, authInfo, options, null); - } - public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { this(repository, manifest, authInfo, options, listener, true); } 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 f6e1c86a591..97f2ef9ee1b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -52,14 +52,6 @@ public class DefaultLauncher extends Launcher { private final LibraryAnalyzer analyzer; - public DefaultLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options) { - this(repository, manifest, authInfo, options, null); - } - - public DefaultLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { - this(repository, manifest, authInfo, options, listener, true); - } - public DefaultLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { super(repository, manifest, authInfo, options, listener, daemon); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java index c20c05a8996..21cbb2aaa4e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java @@ -39,14 +39,6 @@ public abstract class Launcher { protected final ProcessListener listener; protected final boolean daemon; - public Launcher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options) { - this(repository, manifest, authInfo, options, null); - } - - public Launcher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { - this(repository, manifest, authInfo, options, listener, true); - } - public Launcher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { this.repository = repository; this.manifest = manifest; From bd59499d4e63cb1d9b169f9340ec589beefa9d2f Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 4 Aug 2026 22:01:46 +0800 Subject: [PATCH 049/199] Bind Launcher to GameInstance while keeping the launch manifest separate Assisted-by: grok-build:grok-4.5 --- .../jackhuang/hmcl/game/HMCLGameLauncher.java | 31 ++++++--- .../jackhuang/hmcl/game/LauncherHelper.java | 2 +- .../jackhuang/hmcl/game/GameRepository.java | 9 --- .../hmcl/launch/DefaultLauncher.java | 64 +++++++++++-------- .../org/jackhuang/hmcl/launch/Launcher.java | 56 ++++++++++++---- 5 files changed, 108 insertions(+), 54 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java index b09fd4f0eee..e13cd3d5d31 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameLauncher.java @@ -42,12 +42,27 @@ */ public final class HMCLGameLauncher extends DefaultLauncher { - public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { - this(repository, manifest, authInfo, options, listener, true); + /// Creates a launcher with daemon process monitors. + /// + /// @param instance the instance being launched + /// @param manifest the effective launch-time manifest + /// @param authInfo authentication information for the game process + /// @param options launch options + /// @param listener process listener, or `null` to inherit IO + public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener) { + this(instance, manifest, authInfo, options, listener, true); } - public HMCLGameLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { - super(repository, manifest, authInfo, options, listener, daemon); + /// Creates a launcher for the given instance and launch plan. + /// + /// @param instance the instance being launched + /// @param manifest the effective launch-time manifest + /// @param authInfo authentication information for the game process + /// @param options launch options + /// @param listener process listener, or `null` to inherit IO + /// @param daemon whether monitors should be daemon threads + public HMCLGameLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { + super(instance, manifest, authInfo, options, listener, daemon); } @Override @@ -62,7 +77,7 @@ private void generateOptionsTxt() { if (options.isDisableAutoGameOptions()) return; - Path runDir = repository.getRunDirectory(manifest.id()); + Path runDir = instance.getRunDirectory(); Path optionsFile = runDir.resolve("options.txt"); Path configFolder = runDir.resolve("config"); @@ -87,8 +102,8 @@ private void generateOptionsTxt() { * 1.11 ~ 1.12 : zh_cn works fine, zh_CN will display Chinese but the language setting will incorrectly show English as selected * 1.13+ : zh_cn works fine, zh_CN will automatically switch to English */ - GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(manifest)); - if (gameVersion.compareTo("1.1") < 0) + GameVersionNumber gameVersion = instance.getVersion(); + if (gameVersion == GameVersionNumber.unknown() || gameVersion.compareTo("1.1") < 0) return; String lang = normalizedLanguageTag(locale, gameVersion); @@ -176,7 +191,7 @@ private Path extractLwjglUnsafeAgent() throws IOException { Library library = new Library(new Artifact("org.glavo", "lwjgl-unsafe-agent", agentVersion)); String fileName = library.artifact().getFileName(); - Path agentPath = repository.getLayout().getLibraryFile(manifest.id(), library).toAbsolutePath().normalize(); + Path agentPath = instance.getLayout().getLibraryFile(instance.getId(), library).toAbsolutePath().normalize(); if (agentPath.toString().contains("=")) { throw new IOException("Invalid library path: " + agentPath); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java index aae5f2618ee..bf557c9e5d8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -299,7 +299,7 @@ private void launch0() { LOG.info("Here's the structure of game mod directory:\n" + FileUtils.printFileStructure(gameInstance.getModsDirectory(), 10)); return new HMCLGameLauncher( - repository, + gameInstance, version.get(), authInfo, launchOptions, 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 8471895bcbd..b829323e02f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -189,15 +189,6 @@ default Optional getGameVersion(GameInstanceID instanceId) throws NoSuch return getGameVersion(getInstanceManifest(instanceId)); } - /// Returns the primary client jar path for an instance. - /// - /// @param instanceId the instance id - /// @return the primary client jar path - /// @throws NoSuchGameInstanceException if the instance is not loaded in this repository - default Path getInstanceJar(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getInstanceJar(getResolvedInstanceManifest(instanceId).launchManifest()); - } - /// Renames an instance and updates repository-managed references. /// /// @param from the current instance id 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 97f2ef9ee1b..c451474a115 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -52,10 +52,12 @@ public class DefaultLauncher extends Launcher { private final LibraryAnalyzer analyzer; - public DefaultLauncher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { - super(repository, manifest, authInfo, options, listener, daemon); + public DefaultLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { + super(instance, manifest, authInfo, options, listener, daemon); - this.analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null)); + GameVersionNumber version = instance.getVersion(); + this.analyzer = LibraryAnalyzer.analyze(manifest, + version == GameVersionNumber.unknown() ? null : version.toString()); } private Command generateCommandLine(Path nativeFolder) throws IOException { @@ -150,11 +152,11 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { if (!options.isNoGeneratedJVMArgs()) { appendJvmArgs(res); - res.addDefault("-Dminecraft.client.jar=", FileUtils.getAbsolutePath(repository.getInstanceJar(manifest))); + res.addDefault("-Dminecraft.client.jar=", FileUtils.getAbsolutePath(instance.getRepository().getInstanceJar(manifest))); if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { res.addDefault("-Xdock:name=", "Minecraft " + manifest.id()); - repository.getAssetObject(manifest.id(), manifest.getAssetIndex().getId(), "icons/minecraft.icns") + instance.getRepository().getAssetObject(instance.getId(), manifest.getAssetIndex().getId(), "icons/minecraft.icns") .ifPresent(minecraftIcns -> { res.addDefault("-Xdock:icon=", FileUtils.getAbsolutePath(minecraftIcns)); }); @@ -273,25 +275,25 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { } } - Set classpath = repository.getClasspath(manifest); + Set classpath = instance.getRepository().getClasspath(manifest); if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) { classpath.removeIf(c -> c.contains("2.9.4-nightly-20150209")); } - Path jar = repository.getInstanceJar(manifest); + Path jar = instance.getRepository().getInstanceJar(manifest); if (!Files.isRegularFile(jar)) throw new IOException("Minecraft jar does not exist"); classpath.add(FileUtils.getAbsolutePath(jar.toAbsolutePath())); // Provided Minecraft arguments - Path gameAssets = repository.getActualAssetDirectory(manifest.id(), manifest.getAssetIndex().getId()); + Path gameAssets = instance.getRepository().getActualAssetDirectory(instance.getId(), manifest.getAssetIndex().getId()); Map configuration = getConfigurations(); configuration.put("${classpath}", String.join(File.pathSeparator, classpath)); configuration.put("${game_assets}", FileUtils.getAbsolutePath(gameAssets)); configuration.put("${assets_root}", FileUtils.getAbsolutePath(gameAssets)); - Optional gameVersion = repository.getGameVersion(manifest); + Optional gameVersion = findGameVersion(); // lwjgl assumes path to native libraries encoded by ASCII. // Here is a workaround for this issue: https://github.com/HMCL-dev/HMCL/issues/1141. @@ -455,7 +457,7 @@ public void decompressNatives(Path destination) throws NotDecompressingNativesEx FileUtils.cleanDirectoryQuietly(destination); for (Library library : manifest.getLibraries()) if (library.isNative()) - new Unzipper(repository.getLayout().getLibraryFile(manifest.id(), library), destination) + new Unzipper(instance.getLayout().getLibraryFile(instance.getId(), library), destination) .setFilter((zipEntry, destFile, relativePath) -> { if (!zipEntry.isDirectory() && !zipEntry.isUnixSymlink() && Files.isRegularFile(destFile) @@ -481,12 +483,24 @@ public void decompressNatives(Path destination) throws NotDecompressingNativesEx } } + + /// Returns the detected Minecraft version string for this instance, if known. + /// + /// @return the version string, or empty when detection failed + private Optional findGameVersion() { + GameVersionNumber version = instance.getVersion(); + if (version == GameVersionNumber.unknown()) { + return Optional.empty(); + } + return Optional.of(version.toString()); + } + private boolean isUsingLog4j() { - return GameVersionNumber.compare(repository.getGameVersion(manifest).orElse("1.7"), "1.7") >= 0; + return GameVersionNumber.compare(findGameVersion().orElse("1.7"), "1.7") >= 0; } public Path getLog4jConfigurationFile() { - return repository.getLayout().getInstanceRoot(manifest.id()).resolve("log4j2.xml"); + return instance.getInstanceRoot().resolve("log4j2.xml"); } public void extractLog4jConfigurationFile() throws IOException { @@ -494,7 +508,7 @@ public void extractLog4jConfigurationFile() throws IOException { String sourcePath; - if (GameVersionNumber.asGameVersion(repository.getGameVersion(manifest)).compareTo("1.12") < 0) { + if (GameVersionNumber.asGameVersion(findGameVersion()).compareTo("1.12") < 0) { if (options.isEnableDebugLogOutput()) { sourcePath = "/assets/game/log4j2-1.7-debug.xml"; } else { @@ -523,32 +537,32 @@ protected Map getConfigurations() { pair("${version_name}", Optional.ofNullable(options.getVersionName()).orElse(manifest.id().toString())), pair("${profile_name}", Optional.ofNullable(options.getProfileName()).orElse("Minecraft")), pair("${version_type}", Optional.ofNullable(options.getVersionType()).orElse(manifest.type() != null ? manifest.type().getId() : ReleaseType.UNKNOWN.getId())), - pair("${game_directory}", FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id()))), + pair("${game_directory}", FileUtils.getAbsolutePath(instance.getRunDirectory())), pair("${user_type}", authInfo.getUserType()), pair("${assets_index_name}", manifest.getAssetIndex().getId()), pair("${user_properties}", authInfo.getUserProperties()), pair("${resolution_width}", options.getWidth().toString()), pair("${resolution_height}", options.getHeight().toString()), - pair("${library_directory}", FileUtils.getAbsolutePath(repository.getLayout().getLibrariesDirectory())), + pair("${library_directory}", FileUtils.getAbsolutePath(instance.getLayout().getLibrariesDirectory())), pair("${classpath_separator}", File.pathSeparator), - pair("${primary_jar}", FileUtils.getAbsolutePath(repository.getInstanceJar(manifest))), + pair("${primary_jar}", FileUtils.getAbsolutePath(instance.getRepository().getInstanceJar(manifest))), pair("${language}", Locale.getDefault().toLanguageTag()), // defined by HMCL // libraries_directory stands for historical reasons here. We don't know the official launcher // had already defined "library_directory" as the placeholder for path to ".minecraft/libraries" // when we propose this placeholder. - pair("${libraries_directory}", FileUtils.getAbsolutePath(repository.getLayout().getLibrariesDirectory())), + pair("${libraries_directory}", FileUtils.getAbsolutePath(instance.getLayout().getLibrariesDirectory())), // file_separator is used in -DignoreList pair("${file_separator}", File.separator), - pair("${primary_jar_name}", FileUtils.getName(repository.getInstanceJar(manifest))) + pair("${primary_jar_name}", FileUtils.getName(instance.getRepository().getInstanceJar(manifest))) ); } /// Returns the native library directory selected by the launch options. private Path getNativeFolder() { if (StringUtils.isBlank(options.getNativesDir())) { - return repository.getNativeDirectory(manifest.id(), options.getJava().getPlatform()); + return instance.getNativeDirectory(options.getJava().getPlatform()); } return Path.of(options.getNativesDir()); @@ -579,7 +593,7 @@ public ManagedProcess launch() throws IOException, InterruptedException { if (isUsingLog4j()) extractLog4jConfigurationFile(); - Path runDirectory = repository.getRunDirectory(manifest.id()); + Path runDirectory = instance.getRunDirectory(); if (StringUtils.isNotBlank(options.getPreLaunchCommand())) { ProcessBuilder builder = new ProcessBuilder(StringUtils.tokenize(options.getPreLaunchCommand(), getEnvVars(nativeFolder))).directory(runDirectory.toFile()); @@ -614,8 +628,8 @@ private Map getEnvVars(Path nativeFolder) { Map env = new LinkedHashMap<>(); env.put("INST_NAME", versionName); env.put("INST_ID", versionName); - env.put("INST_DIR", FileUtils.getAbsolutePath(repository.getLayout().getInstanceRoot(manifest.id()))); - env.put("INST_MC_DIR", FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id()))); + env.put("INST_DIR", FileUtils.getAbsolutePath(instance.getInstanceRoot())); + env.put("INST_MC_DIR", FileUtils.getAbsolutePath(instance.getRunDirectory())); env.put("INST_JAVA", options.getJava().getBinary().toString()); if (options.getRenderer() instanceof Renderer.Driver driver) { @@ -774,7 +788,7 @@ else if (!isWindows && !(scriptExtension.equalsIgnoreCase("sh") || scriptExtensi writer.newLine(); } writer.write("Set-Location -LiteralPath "); - writer.write(CommandBuilder.pwshString(FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id())))); + writer.write(CommandBuilder.pwshString(FileUtils.getAbsolutePath(instance.getRunDirectory()))); writer.newLine(); @@ -818,7 +832,7 @@ else if (!isWindows && !(scriptExtension.equalsIgnoreCase("sh") || scriptExtensi writer.newLine(); } writer.newLine(); - writer.write(new CommandBuilder().addAll("cd", "/D", FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id()))).toString()); + writer.write(new CommandBuilder().addAll("cd", "/D", FileUtils.getAbsolutePath(instance.getRunDirectory())).toString()); } else { writer.write("#!/usr/bin/env bash"); writer.newLine(); @@ -830,7 +844,7 @@ else if (!isWindows && !(scriptExtension.equalsIgnoreCase("sh") || scriptExtensi writer.write(new CommandBuilder().addAll("ln", "-s", FileUtils.getAbsolutePath(nativeFolder), commandLine.tempNativeFolder.toString()).toString()); writer.newLine(); } - writer.write(new CommandBuilder().addAll("cd", FileUtils.getAbsolutePath(repository.getRunDirectory(manifest.id()))).toString()); + writer.write(new CommandBuilder().addAll("cd", FileUtils.getAbsolutePath(instance.getRunDirectory())).toString()); } writer.newLine(); if (StringUtils.isNotBlank(options.getPreLaunchCommand())) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java index 21cbb2aaa4e..0e5beda3425 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/Launcher.java @@ -18,29 +18,50 @@ package org.jackhuang.hmcl.launch; import org.jackhuang.hmcl.auth.AuthInfo; +import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameRepository; import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.util.platform.ManagedProcess; import java.io.IOException; import java.nio.file.Path; -/** - * - * @author huangyuhui - */ +/// Builds a process or script that launches a game instance. +/// +/// The [GameInstance] identifies the instance being launched (paths, repository layout, version +/// cache). [#manifest] is the effective launch-time manifest after maintenance and native +/// patching; it must not be assumed equal to [GameInstance#getManifest()] or +/// [GameInstance#getLaunchManifest()]. public abstract class Launcher { - protected final GameRepository repository; + /// The instance being launched. + protected final GameInstance instance; + + /// The effective launch manifest for this launch attempt. protected final GameInstanceManifest manifest; + + /// Authentication information passed to the game process. protected final AuthInfo authInfo; + + /// JVM, game, and process launch options. protected final LaunchOptions options; + + /// Optional process output listener, or `null` when output is inherited. protected final ProcessListener listener; + + /// Whether process monitors should run as daemon threads. protected final boolean daemon; - public Launcher(GameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { - this.repository = repository; + /// Creates a launcher for the given instance and launch plan. + /// + /// @param instance the instance being launched + /// @param manifest the effective launch-time manifest (may differ from the instance storage) + /// @param authInfo authentication information for the game process + /// @param options launch options + /// @param listener process listener, or `null` to inherit IO + /// @param daemon whether monitors should be daemon threads + public Launcher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { + this.instance = instance; this.manifest = manifest; this.authInfo = authInfo; this.options = options; @@ -48,11 +69,24 @@ public Launcher(GameRepository repository, GameInstanceManifest manifest, AuthIn this.daemon = daemon; } - /** - * @param file the file path. - */ + /// Returns the instance being launched. + /// + /// @return the bound [GameInstance] + public GameInstance getInstance() { + return instance; + } + + /// Writes a launch script to the given path. + /// + /// @param file the script path + /// @throws IOException if the script cannot be written public abstract void makeLaunchScript(Path file) throws IOException; + /// Starts the game process. + /// + /// @return the managed process + /// @throws IOException if the process cannot be created or launch preparation fails + /// @throws InterruptedException if interrupted while preparing or starting the process public abstract ManagedProcess launch() throws IOException, InterruptedException; } From 024fed227f4e84a1d03dd8ba62ed909740d49add Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 04:46:33 +0800 Subject: [PATCH 050/199] Bind repository-aware tasks to fixed game instances Assisted-by: codex:gpt-5.6-sol --- .../jackhuang/hmcl/game/LauncherHelper.java | 6 +- .../download/DefaultDependencyManager.java | 79 ++++++++---- .../hmcl/download/DependencyManager.java | 118 ++++++++--------- .../hmcl/download/game/GameDownloadTask.java | 63 +++++++-- .../game/GameVerificationFixTask.java | 41 +++--- .../modpack/curse/CurseCompletionTask.java | 1 + .../mcbbs/McbbsModpackCompletionTask.java | 1 + .../modrinth/ModrinthCompletionTask.java | 1 + .../server/ServerModpackCompletionTask.java | 1 + .../hmcl/game/DefaultGameInstanceTest.java | 122 +++++++++++++++++- 10 files changed, 317 insertions(+), 116 deletions(-) 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 bf557c9e5d8..5e8941578b9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -183,7 +183,7 @@ private void launch0() { if (setting.getInheritable(GameSettings::notCheckGameProperty)) return null; return Task.allOf( - dependencyManager.checkGameCompletionAsync(version.get(), integrityCheck), + dependencyManager.checkGameCompletionAsync(gameInstance, version.get(), integrityCheck), Task.composeAsync(() -> { try { ModpackConfiguration configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedInstanceId)); @@ -191,7 +191,7 @@ private void launch0() { if (provider == null) return null; else return provider.createCompletionTask( dependencyManager, - repository.getInstance(selectedInstanceId)); + gameInstance); } catch (IOException e) { return null; } @@ -229,7 +229,7 @@ private void launch0() { if (gameVersion.isEmpty()) { return null; } - return new GameVerificationFixTask(dependencyManager, gameVersion.get(), version.get()); + return new GameVerificationFixTask(gameInstance, gameVersion.get(), version.get()); }) .thenComposeAsync(() -> { if (setting.getInheritable(GameSettings::allowAutoAgentProperty) 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 060c806219b..4c3f2cc2391 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -27,6 +27,7 @@ import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.io.IOException; import java.nio.file.Files; @@ -37,23 +38,39 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -/** - * Note: This class has no state. - * - * @author huangyuhui - */ +/// Provides downloads and game-component installation for one game repository. public class DefaultDependencyManager extends AbstractDependencyManager { + /// The repository whose layout and registered instances are managed. private final DefaultGameRepository repository; + + /// The provider used to resolve remote download URLs and version lists. private final DownloadProvider downloadProvider; + + /// The cache used to source and retain downloaded artifacts. private final DefaultCacheRepository cacheRepository; + /// Creates a dependency manager for a repository and download context. + /// + /// @param repository the associated game repository + /// @param downloadProvider the remote download provider + /// @param cacheRepository the artifact cache public DefaultDependencyManager(DefaultGameRepository repository, DownloadProvider downloadProvider, DefaultCacheRepository cacheRepository) { this.repository = repository; this.downloadProvider = downloadProvider; this.cacheRepository = cacheRepository; } + /// Ensures that an instance belongs to this manager's repository. + /// + /// @param instance the instance to validate + /// @throws IllegalArgumentException if the instance belongs to another repository + public void validateGameInstance(GameInstance instance) { + if (instance.getRepository() != repository) { + throw new IllegalArgumentException("Game instance and dependency manager belong to different repositories"); + } + } + @Override public DefaultGameRepository getGameRepository() { return repository; @@ -75,15 +92,20 @@ public GameBuilder newGameBuilder() { } @Override - public Task checkGameCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck) { + public Task checkGameCompletionAsync( + GameInstance instance, + GameInstanceManifest manifest, + boolean integrityCheck) { + validateGameInstance(instance); + return Task.allOf( Task.composeAsync(() -> { - Path versionJar = repository.getInstanceJar(manifest); + Path versionJar = instance.getInstanceJarFile(); return Files.notExists(versionJar) || FileUtils.size(versionJar) == 0L - ? new GameDownloadTask(this, null, manifest) + ? new GameDownloadTask(this, null, manifest, versionJar) : null; - }).thenComposeAsync(checkPatchCompletionAsync(manifest, integrityCheck)), + }).thenComposeAsync(checkPatchCompletionAsync(instance, manifest, integrityCheck)), new GameAssetDownloadTask(this, manifest, GameAssetDownloadTask.DOWNLOAD_INDEX_IF_NECESSARY, integrityCheck) .setSignificance(Task.TaskSignificance.MODERATE), new GameLibrariesTask(this, manifest, integrityCheck) @@ -96,15 +118,21 @@ public Task checkLibraryCompletionAsync(GameInstanceManifest manifest, boolea } @Override - public Task checkPatchCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck) { + public Task checkPatchCompletionAsync( + GameInstance instance, + GameInstanceManifest manifest, + boolean integrityCheck) { + validateGameInstance(instance); + return Task.composeAsync(() -> { List> tasks = new ArrayList<>(0); - String gameVersion = repository.getGameVersion(manifest).orElse(null); - if (gameVersion == null) return null; + GameVersionNumber detectedVersion = instance.getVersion(); + if (detectedVersion == GameVersionNumber.unknown()) return null; + String gameVersion = detectedVersion.toString(); - GameInstanceManifest original = repository.getInstanceManifest(manifest.id()); - GameInstanceManifest.Resolved resolvedInstanceManifest = repository.getResolvedInstanceManifest(manifest.id()); + GameInstanceManifest original = instance.getManifest(); + GameInstanceManifest.Resolved resolvedInstanceManifest = instance.getResolvedManifest(); LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedInstanceManifest, gameVersion); for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { @@ -171,6 +199,11 @@ public Task installLibraryAsync(GameInstanceManifest baseV .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getLibraryId(), libraryVersion.getSelfVersion())); } + /// Creates a task that detects and runs a supported local library installer. + /// + /// @param oldVersion the manifest to which the installed patch will be added + /// @param installer the local installer jar + /// @return the task producing the updated manifest public Task installLibraryAsync(GameInstanceManifest oldVersion, Path installer) { return Task .composeAsync(() -> { @@ -199,17 +232,19 @@ public Task installLibraryAsync(GameInstanceManifest oldVe .thenApplyAsync(patch -> patch == null ? oldVersion : oldVersion.addPatch(patch)); } + /// Indicates that a local library installer is not recognized by any supported installer. public static class UnsupportedLibraryInstallerException extends Exception { + + /// Creates an unsupported-installer exception. + public UnsupportedLibraryInstallerException() { + } } - /** - * Remove installed library. - * Will try to remove libraries and patches. - * - * @param manifest not resolved instance manifest - * @param libraryId forge/liteloader/optifine/fabric - * @return task to remove the specified library - */ + /// Creates a task that removes a loader's libraries and patch from a manifest. + /// + /// @param manifest the unresolved instance manifest + /// @param libraryId the patch identifier, such as `forge`, `optifine`, or `fabric` + /// @return the task producing the updated independent manifest public Task removeLibraryAsync(GameInstanceManifest manifest, String libraryId) { // MaintainTask requires version that does not inherits from any version. // If we want to remove a library in dependent version, we should keep the dependents not changed diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java index 16a6f43263d..73b75c8df93 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java @@ -17,87 +17,83 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameRepository; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.CacheRepository; -/** - * Do everything that will connect to Internet. - * Downloading Minecraft files. - * - * @author huangyuhui - */ +/// Provides repository-scoped services for downloading and installing game components. public interface DependencyManager { - /** - * The relied game repository. - */ + /// Returns the game repository used for path resolution and instance updates. + /// + /// @return the associated game repository GameRepository getGameRepository(); - /** - * The cache repository - */ + /// Returns the cache repository used by downloads. + /// + /// @return the associated cache repository CacheRepository getCacheRepository(); - /** - * Check if the game is complete. - * Check libraries, assets files and so on. - * - * @return the task to check game completion. - */ - Task checkGameCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck); + /// Creates a task that completes the files required to launch an instance. + /// + /// The instance fixes snapshot-bound identity and storage paths. `manifest` is the effective + /// launch manifest and may differ from [GameInstance#getManifest()] after launch-time + /// maintenance or patching. The instance must belong to [#getGameRepository()]. + /// + /// @param instance the fixed registered instance being prepared + /// @param manifest the effective launch manifest to inspect + /// @param integrityCheck whether existing files must be verified + /// @return the completion task + /// @throws IllegalArgumentException if `instance` belongs to another repository + Task checkGameCompletionAsync(GameInstance instance, GameInstanceManifest manifest, boolean integrityCheck); - /** - * Check if libraries of this version in complete. - * If not, download missing libraries if possible. - * - * @return the task to check game completion. - */ + /// Creates a task that completes the libraries declared by a manifest. + /// + /// @param manifest the manifest whose libraries are checked + /// @param integrityCheck whether existing libraries must be verified + /// @return the library-completion task Task checkLibraryCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck); - /** - * Check if patches of this version in complete. - * If not, reinstall the patch if possible. - * - * @param manifest the version to be checked - * @param integrityCheck check if some libraries are corrupt. - * @return the task to check patches completion. - */ - Task checkPatchCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck); + /// Creates a task that repairs installable patches required by an instance. + /// + /// The stored and resolved manifests used to identify installed patches are read from + /// `instance`; `manifest` supplies the effective launch-time library set. The instance must + /// belong to [#getGameRepository()]. + /// + /// @param instance the fixed registered instance being prepared + /// @param manifest the effective launch manifest to inspect + /// @param integrityCheck whether existing patch libraries must be verified + /// @return the patch-completion task + /// @throws IllegalArgumentException if `instance` belongs to another repository + Task checkPatchCompletionAsync(GameInstance instance, GameInstanceManifest manifest, boolean integrityCheck); - /** - * The builder to build a brand new game then libraries such as Forge, LiteLoader and OptiFine. - */ + /// Creates a builder for installing a new game instance and optional loaders. + /// + /// @return a new game builder GameBuilder newGameBuilder(); - /** - * Install a library to a version. - * **Note**: Installing a library may change the version.json. - * - * @param gameVersion the Minecraft version that the library relies on. - * @param baseVersion the version.json. - * @param libraryId the type of being installed library. i.e. "forge", "liteloader", "optifine" - * @param libraryVersion the version of being installed library. - * @return the task to install the specific library. - */ + /// Creates a task that installs a loader or patch into a base manifest. + /// + /// @param gameVersion the Minecraft version required by the library + /// @param baseVersion the base manifest + /// @param libraryId the registered library type, such as `forge` or `optifine` + /// @param libraryVersion the library version to install + /// @return the installation task Task installLibraryAsync(String gameVersion, GameInstanceManifest baseVersion, String libraryId, String libraryVersion); - /** - * Install a library to a version. - * **Note**: Installing a library may change the version.json. - * - * @param baseVersion the version.json. - * @param libraryVersion the remote version of being installed library. - * @return the task to install the specific library. - */ + /// Creates a task that installs a remote loader or patch into a base manifest. + /// + /// @param baseVersion the base manifest + /// @param libraryVersion the remote library version to install + /// @return the installation task Task installLibraryAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion); - /** - * Get registered version list. - * - * @param id the id of version list. i.e. game, forge, liteloader, optifine - * @throws IllegalArgumentException if the version list of specific id is not found. - */ + /// Returns a registered remote-version list. + /// + /// @param id the list identifier, such as `game`, `forge`, or `optifine` + /// @return the registered version list + /// @throws IllegalArgumentException if no list is registered for `id` VersionList getVersionList(String id); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java index 5bb59fa6474..0b214a7b08f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java @@ -22,42 +22,86 @@ import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.CacheRepository; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; -/** - * Task to download Minecraft jar - * @author huangyuhui - */ +/// Downloads a Minecraft client jar to a repository-resolved or explicitly fixed destination. +@NotNullByDefault public final class GameDownloadTask extends Task { + + /// The dependency manager supplying downloads and cache access. private final DefaultDependencyManager dependencyManager; - private final String gameVersion; + + /// The optional Minecraft version used to locate a cached jar candidate. + private final @Nullable String gameVersion; + + /// The resolved manifest that supplies client download metadata. private final GameInstanceManifest manifest; + + /// The explicit destination fixed when this task is created, or `null` to resolve it at execution. + private final @Nullable Path jar; + + /// The file-download task created during execution. private final List> dependencies = new ArrayList<>(); - public GameDownloadTask(DefaultDependencyManager dependencyManager, String gameVersion, GameInstanceManifest manifest) { + /// Creates a task whose destination is resolved from the repository when execution starts. + /// + /// @param dependencyManager the dependency manager used for resolution and downloading + /// @param gameVersion the Minecraft version used as a cache key, or `null` + /// @param manifest the manifest supplying client download metadata + public GameDownloadTask( + DefaultDependencyManager dependencyManager, + @Nullable String gameVersion, + GameInstanceManifest manifest) { + this.dependencyManager = dependencyManager; + this.gameVersion = gameVersion; + this.manifest = manifest.resolve(dependencyManager.getGameRepository()); + this.jar = null; + + setSignificance(TaskSignificance.MODERATE); + } + + /// Creates a task that writes the client jar to an explicit fixed destination. + /// + /// @param dependencyManager the dependency manager used for resolution and downloading + /// @param gameVersion the Minecraft version used as a cache key, or `null` + /// @param manifest the manifest supplying client download metadata + /// @param jar the destination jar path + public GameDownloadTask( + DefaultDependencyManager dependencyManager, + @Nullable String gameVersion, + GameInstanceManifest manifest, + Path jar) { this.dependencyManager = dependencyManager; this.gameVersion = gameVersion; this.manifest = manifest.resolve(dependencyManager.getGameRepository()); + this.jar = jar; setSignificance(TaskSignificance.MODERATE); } + /// Returns the download created by [#execute()], if execution has started. + /// + /// @return the live dependency collection @Override public Collection> getDependencies() { return dependencies; } + /// Creates the file-download dependency for the configured destination. @Override public void execute() { - Path jar = dependencyManager.getGameRepository().getInstanceJar(manifest); - + Path destination = jar != null + ? jar + : dependencyManager.getGameRepository().getInstanceJar(manifest); var task = new FileDownloadTask( dependencyManager.getDownloadProvider().injectURLWithCandidates(manifest.getDownloadInfo().getUrl()), - jar, + destination, FileDownloadTask.IntegrityCheck.of(CacheRepository.SHA1, manifest.getDownloadInfo().getSha1())); task.setCaching(true); task.setCacheRepository(dependencyManager.getCacheRepository()); @@ -67,5 +111,4 @@ public void execute() { dependencies.add(task); } - } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java index 32d40f74181..f53bcff40f6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java @@ -17,48 +17,52 @@ */ package org.jackhuang.hmcl.download.game; -import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -/** - * Remove class digital verification file in game jar - * @author huangyuhui - */ +/// Removes obsolete signature files from a legacy Forge instance's fixed client jar. +@NotNullByDefault public final class GameVerificationFixTask extends Task { - private final DefaultDependencyManager dependencyManager; + + /// The snapshot-bound instance whose client jar may be modified. + private final GameInstance instance; + + /// The detected Minecraft version. private final String gameVersion; + + /// The effective launch manifest used to detect Forge. private final GameInstanceManifest manifest; - private final List> dependencies = new ArrayList<>(); - public GameVerificationFixTask(DefaultDependencyManager dependencyManager, String gameVersion, GameInstanceManifest manifest) { - this.dependencyManager = dependencyManager; + /// Creates a task for a fixed instance and effective launch manifest. + /// + /// @param instance the instance whose client jar may be modified + /// @param gameVersion the detected Minecraft version + /// @param manifest the effective launch manifest used to detect Forge + public GameVerificationFixTask(GameInstance instance, String gameVersion, GameInstanceManifest manifest) { + this.instance = instance; this.gameVersion = gameVersion; this.manifest = manifest; setSignificance(TaskSignificance.MODERATE); } - @Override - public Collection> getDependencies() { - return dependencies; - } - + /// Removes legacy Mojang signature entries when this is a pre-1.6 Forge installation. + /// + /// @throws IOException if the client jar cannot be opened or modified @Override public void execute() throws IOException { - Path jar = dependencyManager.getGameRepository().getInstanceJar(manifest); + Path jar = instance.getInstanceJarFile(); LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, gameVersion); if (Files.exists(jar) && GameVersionNumber.compare(gameVersion, "1.6") < 0 && analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) { @@ -68,5 +72,4 @@ public void execute() throws IOException { } } } - } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java index 75139b7e3c6..aadc19a1532 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java @@ -90,6 +90,7 @@ public CurseCompletionTask( DefaultDependencyManager dependencyManager, DefaultGameInstance instance, @Nullable CurseManifest manifest) { + dependencyManager.validateGameInstance(instance); this.dependency = dependencyManager; this.instance = instance; this.modManager = instance.getModManager(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java index 9fd1fefae96..6bc27fff955 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java @@ -88,6 +88,7 @@ public McbbsModpackCompletionTask( DefaultDependencyManager dependencyManager, DefaultGameInstance instance, @Nullable ModpackConfiguration configuration) { + dependencyManager.validateGameInstance(instance); this.dependency = dependencyManager; this.instance = instance; this.modManager = instance.getModManager(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java index ca894ed63a2..bf712321f6d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java @@ -85,6 +85,7 @@ public ModrinthCompletionTask( DefaultDependencyManager dependencyManager, DefaultGameInstance instance, @Nullable ModrinthManifest manifest) { + dependencyManager.validateGameInstance(instance); this.dependency = dependencyManager; this.instance = instance; this.modManager = instance.getModManager(); 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 c24e0146108..cf2b392f09f 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 @@ -83,6 +83,7 @@ public ServerModpackCompletionTask( DefaultDependencyManager dependencyManager, DefaultGameInstance instance, @Nullable ModpackConfiguration manifest) { + dependencyManager.validateGameInstance(instance); this.dependencyManager = dependencyManager; this.instance = instance; this.configurationFile = instance.getRepository().getModpackConfiguration(instance.getId()); 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 99006fcc431..9c7b4a761d6 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -17,6 +17,16 @@ */ package org.jackhuang.hmcl.game; +import org.jackhuang.hmcl.download.DefaultCacheRepository; +import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.MojangDownloadProvider; +import org.jackhuang.hmcl.download.game.GameDownloadTask; +import org.jackhuang.hmcl.download.game.GameVerificationFixTask; +import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask; +import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask; +import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask; +import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask; +import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -27,14 +37,19 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Optional; import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /// Tests snapshot-bound behavior of [DefaultGameInstance]. @NotNullByDefault @@ -99,6 +114,72 @@ public void testExplicitManifestDoesNotReuseDifferentCachedManifest(@TempDir Pat assertEquals(Optional.of("1.21.1"), repository.getGameVersion(requestedManifest)); } + /// A game download with an explicit destination does not follow a later repository snapshot. + @Test + public void testGameDownloadKeepsExplicitDestination(@TempDir Path tempDirectory) { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("cache"))); + GameInstanceManifest manifest = new GameInstanceManifest(new GameInstanceID("instance")); + Path destination = tempDirectory.resolve("fixed.jar"); + + GameDownloadTask task = new GameDownloadTask(dependencyManager, null, manifest, destination); + task.execute(); + + FileDownloadTask download = (FileDownloadTask) task.getDependencies().iterator().next(); + assertEquals(destination, download.getPath()); + } + + /// Legacy verification fixes the captured instance jar rather than a newer same-id snapshot. + @Test + public void testVerificationFixKeepsCapturedInstance(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceManifest manifest = new GameInstanceManifest(instanceId).withLibraries(List.of( + new Library(new Artifact("net.minecraftforge", "forge", "1.5.2-7.8.1.738")))); + + TestGameInstance captured = repository.publish( + instanceId, + manifest, + tempDirectory.resolve("versions/instance/captured.json")); + writeSignedJar(captured.getInstanceJarFile()); + + TestGameInstance current = repository.publish( + instanceId, + manifest, + tempDirectory.resolve("versions/instance/current.json")); + writeSignedJar(current.getInstanceJarFile()); + + new GameVerificationFixTask(captured, "1.5.2", manifest).execute(); + + assertFalse(hasZipEntry(captured.getInstanceJarFile(), "META-INF/MOJANG_C.DSA")); + assertFalse(hasZipEntry(captured.getInstanceJarFile(), "META-INF/MOJANG_C.SF")); + assertTrue(hasZipEntry(current.getInstanceJarFile(), "META-INF/MOJANG_C.DSA")); + assertTrue(hasZipEntry(current.getInstanceJarFile(), "META-INF/MOJANG_C.SF")); + } + + /// Dependency managers and modpack completion tasks reject cross-repository instances. + @Test + public void testDependencyManagerValidatesInstanceRepository(@TempDir Path tempDirectory) { + TestRepository instanceRepository = new TestRepository(tempDirectory.resolve("instance")); + TestRepository managerRepository = new TestRepository(tempDirectory.resolve("manager")); + TestGameInstance instance = instanceRepository.publish( + new GameInstanceID("instance"), + new GameInstanceManifest(new GameInstanceID("instance"))); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + managerRepository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("cache"))); + + assertThrows(IllegalArgumentException.class, () -> dependencyManager.validateGameInstance(instance)); + assertThrows(IllegalArgumentException.class, () -> new CurseCompletionTask(dependencyManager, instance)); + assertThrows(IllegalArgumentException.class, () -> new McbbsModpackCompletionTask(dependencyManager, instance)); + assertThrows(IllegalArgumentException.class, () -> new ModrinthCompletionTask(dependencyManager, instance)); + assertThrows(IllegalArgumentException.class, () -> new ServerModpackCompletionTask(dependencyManager, instance)); + } + /// Non-conventional JSON/jar basenames are kept on disk and recorded on the instance. @Test public void testRefreshRecordsNonConventionalStoragePaths(@TempDir Path tempDirectory) throws IOException { @@ -136,6 +217,32 @@ private static void writeVersionJar(Path jar, String version) throws IOException } } + /// Writes a jar containing the legacy signature entries removed before launching Forge. + /// + /// @param jar the jar path + private static void writeSignedJar(Path jar) throws IOException { + Files.createDirectories(jar.getParent()); + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(jar))) { + output.putNextEntry(new ZipEntry("META-INF/MOJANG_C.DSA")); + output.write(1); + output.closeEntry(); + output.putNextEntry(new ZipEntry("META-INF/MOJANG_C.SF")); + output.write(1); + output.closeEntry(); + } + } + + /// Returns whether a zip contains an entry with the given name. + /// + /// @param zipFile the zip path + /// @param entryName the entry name + /// @return `true` when the entry exists + private static boolean hasZipEntry(Path zipFile, String entryName) throws IOException { + try (ZipFile zip = new ZipFile(zipFile.toFile())) { + return zip.getEntry(entryName) != null; + } + } + /// Minimal repository implementation for snapshot-bound instance tests. @NotNullByDefault private static final class TestRepository extends DefaultGameRepository { @@ -169,8 +276,21 @@ protected TestGameInstance createInstance( /// @param manifest the stored manifest /// @return the published instance private TestGameInstance publish(GameInstanceID id, GameInstanceManifest manifest) { + return publish(id, manifest, null); + } + + /// Publishes a snapshot containing one test instance with an optional manifest path. + /// + /// @param id the instance id + /// @param manifest the stored manifest + /// @param manifestFile the non-conventional manifest path, or `null` + /// @return the published instance + private TestGameInstance publish( + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { DefaultGameRepositorySnapshot snapshot = newSnapshot(); - TestGameInstance instance = (TestGameInstance) createInstance(snapshot, id, manifest); + TestGameInstance instance = createInstance(snapshot, id, manifest, manifestFile); snapshot.put(instance); publishSnapshot(snapshot); return instance; From 042ada5215ecde142c430acdb884ba2b013210f5 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 04:48:01 +0800 Subject: [PATCH 051/199] Remove redundant constructors from GameListItem, GameItem, LauncherHelper, ExportWizardProvider, and WorldManagePage --- .../main/java/org/jackhuang/hmcl/game/LauncherHelper.java | 5 ----- .../jackhuang/hmcl/ui/export/ExportWizardProvider.java | 8 -------- .../java/org/jackhuang/hmcl/ui/instances/GameItem.java | 5 ----- .../org/jackhuang/hmcl/ui/instances/GameListItem.java | 5 ----- .../org/jackhuang/hmcl/ui/instances/WorldManagePage.java | 8 -------- 5 files changed, 31 deletions(-) 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 5e8941578b9..d36d0221d50 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -102,11 +102,6 @@ public LauncherHelper(HMCLGameInstance gameInstance, Account account) { this.launchingStepsPane.setTitle(i18n("instance.launch")); } - public LauncherHelper(HMCLGameRepository repository, Account account, GameInstanceID selectedInstanceId) { - this(Objects.requireNonNull(repository.findInstance(selectedInstanceId), - () -> "Instance not found: " + selectedInstanceId), account); - } - public HMCLGameInstance getGameInstance() { return gameInstance; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java index 0fa4d64989e..92ad71e2cd8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java @@ -19,10 +19,7 @@ import javafx.scene.Node; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import java.util.Objects; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.ModpackExportInfo; import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask; @@ -56,11 +53,6 @@ public ExportWizardProvider(HMCLGameInstance gameInstance) { this.gameInstance = gameInstance; } - public ExportWizardProvider(HMCLGameRepository repository, GameInstanceID instanceId) { - this(Objects.requireNonNull(repository.findInstance(instanceId), - () -> "Instance not found: " + instanceId)); - } - @Override public void start(SettingsMap settings) { } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java index a517c88e648..34321ead79a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java @@ -56,11 +56,6 @@ public GameItem(HMCLGameInstance gameInstance) { this.gameInstance = gameInstance; } - public GameItem(HMCLGameRepository repository, GameInstanceID instanceId) { - this(Objects.requireNonNull(repository.findInstance(instanceId), - () -> "Instance not found: " + instanceId)); - } - public GameDirectory getGameDirectory() { return gameInstance.getRepository().getGameDirectory(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java index 01eb09fcbe4..44452618255 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java @@ -46,11 +46,6 @@ public GameListItem(HMCLGameInstance gameInstance) { GameDirectoryManager.selectedInstanceProperty())); } - public GameListItem(HMCLGameRepository repository, GameInstanceID instanceId) { - this(Objects.requireNonNull(repository.findInstance(instanceId), - () -> "Instance not found: " + instanceId)); - } - public ReadOnlyBooleanProperty selectedProperty() { return selected; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java index 3bea915abfd..af2113475ee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/WorldManagePage.java @@ -24,10 +24,7 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import java.util.Objects; import org.jackhuang.hmcl.game.World; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -69,11 +66,6 @@ public final class WorldManagePage extends DecoratorAnimatedPage implements Deco private final TabHeader.Tab worldBackupsTab = new TabHeader.Tab<>("worldBackupsPage"); private final TabHeader.Tab dataPackTab = new TabHeader.Tab<>("dataPackListPage"); - public WorldManagePage(World world, HMCLGameRepository repository, GameInstanceID instanceId) { - this(world, Objects.requireNonNull(repository.findInstance(instanceId), - () -> "Instance not found: " + instanceId)); - } - public WorldManagePage(World world, HMCLGameInstance gameInstance) { this.world = world; this.gameInstance = gameInstance; From 32128cbced623ddfce3075d2769922c6b0cfe406 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 04:54:28 +0800 Subject: [PATCH 052/199] Refactor game instance handling in repository classes to streamline instance retrieval and improve clarity --- .../org/jackhuang/hmcl/game/HMCLGameRepository.java | 10 +++++----- .../hmcl/game/HMCLGameRepositorySnapshot.java | 8 ++++++++ .../jackhuang/hmcl/ui/instances/DownloadListPage.java | 9 +++------ .../org/jackhuang/hmcl/ui/instances/GameListPage.java | 9 +++------ .../hmcl/game/DefaultGameRepositorySnapshot.java | 2 +- 5 files changed, 20 insertions(+), 18 deletions(-) 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 5cf4ab202e9..c7cf76532e5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -198,11 +198,11 @@ public Path getRunDirectory(GameInstanceID instanceId) { return resolveInstance(instanceId).getRunDirectory(); } - public Stream getDisplayInstanceManifests() { - return getInstanceManifests().stream() - .filter(v -> !v.isHidden()) - .sorted(Comparator.comparing((GameInstanceManifest v) -> Lang.requireNonNullElse(v.releaseTime(), Instant.EPOCH)) - .thenComparing(v -> VersionNumber.asVersion(v.id().id()))); + public Stream getDisplayInstances() { + return getSnapshot().getInstances().stream() + .filter(it -> !it.getManifest().isHidden()) + .sorted(Comparator.comparing((HMCLGameInstance instance) -> Lang.requireNonNullElse(instance.getLaunchManifest().releaseTime(), Instant.EPOCH)) + .thenComparing(instance -> VersionNumber.asVersion(instance.getId().id()))); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java index 95fcc84e055..1278547ade9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java @@ -19,6 +19,8 @@ import org.jetbrains.annotations.NotNullByDefault; +import java.util.Collection; + /// HMCL repository snapshot, parallel to [HMCLGameInstance] in the instance hierarchy. @NotNullByDefault public class HMCLGameRepositorySnapshot extends DefaultGameRepositorySnapshot { @@ -49,4 +51,10 @@ protected HMCLGameRepositorySnapshot newEmpty() { public HMCLGameRepositorySnapshot clone() { return (HMCLGameRepositorySnapshot) super.clone(); } + + @SuppressWarnings("unchecked") + @Override + public Collection getInstances() { + return (Collection) super.getInstances(); + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java index 68ee9fa72be..b85cc862457 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java @@ -38,10 +38,7 @@ import javafx.scene.input.KeyEvent; import javafx.scene.layout.*; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.RemoteAddonRepository; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; @@ -125,8 +122,8 @@ public void loadInstance(HMCLGameInstance.Optional instance) { if (instanceSelection) { HMCLGameRepository repository = instance.repository(); - instances.setAll(repository.getDisplayInstanceManifests() - .map(GameInstanceManifest::id) + instances.setAll(repository.getDisplayInstances() + .map(DefaultGameInstance::getId) .toList()); selectedInstance.set(repository.getSelectedInstance()); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java index 39cb26de4cc..3e6635c851b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPage.java @@ -66,7 +66,6 @@ import java.nio.file.Path; import java.util.List; import java.util.Locale; -import java.util.Objects; import java.util.function.Predicate; import java.util.regex.Pattern; import java.util.regex.PatternSyntaxException; @@ -157,15 +156,13 @@ private void loadVersions(HMCLGameRepository repository) { setLoading(true); setFailedReason(null); - List versionItems = repository.getDisplayInstanceManifests() - .map(manifest -> repository.findInstance(manifest.id())) - .filter(Objects::nonNull) + List instanceItems = repository.getDisplayInstances() .map(GameListItem::new) .toList(); - sourceList.setAll(versionItems); + sourceList.setAll(instanceItems); - if (versionItems.isEmpty()) { + if (instanceItems.isEmpty()) { setFailedReason(i18n("instance.empty.hint")); } 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 ea274793f69..aa1f2f6b60a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -162,7 +162,7 @@ public int getInstanceCount() { /// {@inheritDoc} @Override - public Collection getInstances() { + public Collection getInstances() { return instances.values().stream() .filter(instance -> !instance.isProvisional()) .toList(); From bce34412904bfd2c07d36e09bea12042729b3182 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 04:57:06 +0800 Subject: [PATCH 053/199] Remove unused generateLaunchScript method from Instances.java --- .../java/org/jackhuang/hmcl/ui/instances/Instances.java | 9 --------- 1 file changed, 9 deletions(-) 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 494a77c6988..ae3868f3f53 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 @@ -295,15 +295,6 @@ public static void generateLaunchScript(HMCLGameInstance gameInstance, Consumer< }); } - /// Resolves the selected instance (which may be missing) and generates a launch script. - @SafeVarargs - public static void generateLaunchScript(HMCLGameRepository repository, GameInstanceID instanceId, Consumer... injecters) { - HMCLGameInstance gameInstance = resolveLaunchInstance(repository, instanceId); - if (gameInstance != null) { - generateLaunchScript(gameInstance, injecters); - } - } - private static boolean isValidScriptExtension(String ext) { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { return ext.equalsIgnoreCase("bat") || ext.equalsIgnoreCase("ps1"); From 86a7f649f6b0f482dde2efec4bf294ff0af7d0ce Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:18:10 +0800 Subject: [PATCH 054/199] Refactor game instance handling to use HMCLGameInstance and improve null safety --- .../hmcl/game/HMCLGameRepository.java | 76 ++++++++++++++----- .../hmcl/setting/GameDirectoryManager.java | 28 ++++--- .../org/jackhuang/hmcl/ui/Controllers.java | 2 +- .../hmcl/ui/download/DownloadPage.java | 12 ++- .../ModpackInstallWizardProvider.java | 4 +- .../hmcl/ui/instances/DownloadListPage.java | 3 +- .../ui/instances/GameAdvancedListItem.java | 38 +++------- .../hmcl/ui/instances/GameListCell.java | 3 +- .../hmcl/ui/instances/GameListItem.java | 6 +- .../hmcl/ui/instances/GameListPopupMenu.java | 3 +- .../hmcl/ui/instances/Instances.java | 71 ++++++----------- .../org/jackhuang/hmcl/ui/main/MainPage.java | 32 +++++--- .../org/jackhuang/hmcl/ui/main/RootPage.java | 16 ++-- .../terracotta/TerracottaControllerPage.java | 2 +- .../hmcl/ui/terracotta/TerracottaPage.java | 13 ++-- .../hmcl/setting/GameDirectoriesTest.java | 62 +++++++++++++++ 16 files changed, 232 insertions(+), 139 deletions(-) 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 c7cf76532e5..2db49cab47c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -18,9 +18,10 @@ package org.jackhuang.hmcl.game; import com.google.gson.JsonParseException; -import javafx.beans.binding.Binding; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectWrapper; import javafx.scene.image.Image; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.DefaultDependencyManager; @@ -73,15 +74,26 @@ public final class HMCLGameRepository extends DefaultGameRepository { private final GameDirectory gameDirectory; /// The selected instance ID persisted for this repository's game directory. - private final ObjectBinding<@Nullable GameInstanceID> selectedInstance; + private final ObjectBinding<@Nullable GameInstanceID> selectedInstanceId; + /// The selected instance resolved from the current repository snapshot. + private final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance; + + /// Publishes notifications after an instance icon changes. public final EventManager onInstanceIconChanged = new EventManager<>(); /// Creates a repository backed by the given game directory. + /// + /// @param gameDirectory the persistent game directory represented by this repository public HMCLGameRepository(GameDirectory gameDirectory) { super(gameDirectory.getPath().toPath()); this.gameDirectory = gameDirectory; - this.selectedInstance = Bindings.valueAt(settings().getSelectedInstance(), gameDirectory.getId()); + this.selectedInstanceId = Bindings.valueAt(settings().getSelectedInstance(), gameDirectory.getId()); + this.selectedInstance = new ReadOnlyObjectWrapper<>(this, "selectedInstance"); + this.selectedInstance.bind(Bindings.createObjectBinding( + this::resolveSelectedInstance, + selectedInstanceId, + snapshotProperty())); gameDirectory.pathProperty().addListener((a, b, newValue) -> changeDirectory(newValue.toPath())); } @@ -156,33 +168,63 @@ public GameDirectory getGameDirectory() { return gameDirectory; } - /// Returns the selected instance ID property for this repository's game directory. - public Binding<@Nullable GameInstanceID> selectedInstanceProperty() { - return selectedInstance; + /// Returns the selected instance resolved from the current repository snapshot. + /// + /// The property is `null` when the persisted selection is absent or is not registered in the + /// current snapshot. Publishing a new snapshot replaces the value with that snapshot's member, + /// even when the selected ID is unchanged. + /// + /// @return the read-only selected-instance property + public ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() { + return selectedInstance.getReadOnlyProperty(); } - /// Returns the selected instance ID for this repository's game directory. - public @Nullable GameInstanceID getSelectedInstance() { + /// Returns the selected instance from the current repository snapshot. + /// + /// @return the selected instance, or `null` when no registered instance is selected + public @Nullable HMCLGameInstance getSelectedInstance() { return selectedInstance.get(); } - /// Sets the selected instance ID for this repository's game directory. - public void setSelectedInstance(@Nullable GameInstanceID instanceId) { - settings().setSelectedInstance(gameDirectory.getId(), instanceId); + /// Persists an instance as this repository's current selection. + /// + /// A stale snapshot member from this repository is accepted; the observable property resolves + /// its ID against the current snapshot. + /// + /// @param instance the instance to select, or `null` to clear the selection + /// @throws IllegalArgumentException if `instance` belongs to another repository + public void setSelectedInstance(@Nullable HMCLGameInstance instance) { + if (instance != null && instance.getRepository() != this) { + throw new IllegalArgumentException("Selected instance belongs to another repository"); + } + settings().setSelectedInstance(gameDirectory.getId(), instance != null ? instance.getId() : null); } - /// Refreshes the selected instance ID after instances are loaded. + /// Restores a valid selected instance after repository instances are loaded. + /// + /// If the persisted ID is not registered, the first indexed instance is selected. If the + /// repository is empty, the persisted selection is cleared. public void refreshSelectedInstance() { - @Nullable GameInstanceID selectedInstance = settings().getSelectedInstance(gameDirectory.getId()); - @Nullable GameInstanceID refreshedInstance = selectedInstance; - if (refreshedInstance == null || !hasInstance(refreshedInstance)) { - refreshedInstance = getInstanceManifests().isEmpty() ? null : getInstanceManifests().iterator().next().id(); + @Nullable GameInstanceID persistedId = selectedInstanceId.get(); + @Nullable HMCLGameInstance refreshedInstance = persistedId != null ? findInstance(persistedId) : null; + if (refreshedInstance == null) { + refreshedInstance = getSnapshot().getInstances().stream().findFirst().orElse(null); } - if (!Objects.equals(selectedInstance, refreshedInstance)) { + + @Nullable GameInstanceID refreshedId = refreshedInstance != null ? refreshedInstance.getId() : null; + if (!Objects.equals(persistedId, refreshedId)) { setSelectedInstance(refreshedInstance); } } + /// Resolves the persisted selected ID from the current repository snapshot. + /// + /// @return the current snapshot member, or `null` when the selected ID is absent or unregistered + private @Nullable HMCLGameInstance resolveSelectedInstance() { + @Nullable GameInstanceID instanceId = selectedInstanceId.get(); + return instanceId != null ? findInstance(instanceId) : null; + } + /// Returns a dependency manager using the currently selected download provider. public DefaultDependencyManager getDependency() { return getDependency(DownloadProviders.getDownloadProvider()); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java index 0a40fb98728..900e1f567ba 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java @@ -24,7 +24,7 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.event.EventBus; import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.util.PortablePath; import org.jackhuang.hmcl.util.i18n.I18n; @@ -141,11 +141,12 @@ private static boolean isGameDirectoryPath(GameDirectory gameDirectory, Portable /// The selected game repository, or `null` before the fallback game directory is resolved. private static final ObjectProperty<@UnknownNullability HMCLGameRepository> selectedRepository = new SimpleObjectProperty<>(GameDirectoryManager.class, "selectedRepository"); - /// The selected instance ID projected from the selected repository. - private static final ReadOnlyObjectWrapper selectedInstance = new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance"); + /// The selected instance projected from the selected repository's current snapshot. + private static final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance = + new ReadOnlyObjectWrapper<>(GameDirectoryManager.class, "selectedInstance"); /// Updates [#selectedInstance] when the selected repository changes its selected instance. - private static final ChangeListener selectedRepositoryInstanceListener = + private static final ChangeListener<@Nullable HMCLGameInstance> selectedRepositoryInstanceListener = (observable, oldValue, newValue) -> selectedInstance.set(newValue); /// Initializes game directory state from the stores loaded by [SettingsManager]. @@ -480,17 +481,26 @@ public static ObjectProperty selectedRepositoryProperty() { } /// Returns the selected instance property projected from the selected repository. - public static ReadOnlyObjectProperty<@Nullable GameInstanceID> selectedInstanceProperty() { + /// + /// The value is `null` when the selected repository has no registered selected instance. + /// + /// @return the read-only selected-instance property + public static ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstanceProperty() { return selectedInstance.getReadOnlyProperty(); } - /// Returns the selected instance ID for the selected repository. - public static @Nullable GameInstanceID getSelectedInstance() { + /// Returns the selected instance from the selected repository's current snapshot. + /// + /// @return the selected instance, or `null` when none is registered + public static @Nullable HMCLGameInstance getSelectedInstance() { return getSelectedRepository().getSelectedInstance(); } - /// Sets the selected instance ID for the selected repository. - public static void setSelectedInstance(@Nullable GameInstanceID instance) { + /// Sets the selected instance for the selected repository. + /// + /// @param instance the instance to select, or `null` to clear the selection + /// @throws IllegalArgumentException if `instance` belongs to another repository + public static void setSelectedInstance(@Nullable HMCLGameInstance instance) { getSelectedRepository().setSelectedInstance(instance); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 94db18eae42..53996f7bd28 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -553,7 +553,7 @@ public static void onHyperlinkAction(String href) { break; case "hmcl://game/launch": var repository = GameDirectoryManager.getSelectedRepository(); - Instances.launch(repository, repository.getSelectedInstance(), LauncherHelper::setKeep); + Instances.launch(repository.getSelectedInstance(), LauncherHelper::setKeep); break; } } else { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index 4dbf0408e15..46d5a335017 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 @@ -46,7 +46,6 @@ import org.jackhuang.hmcl.ui.decorator.DecoratorPage; import org.jackhuang.hmcl.ui.instances.DownloadListPage; import org.jackhuang.hmcl.ui.instances.HMCLLocalizedDownloadListPage; -import org.jackhuang.hmcl.ui.instances.GameInstancePage; import org.jackhuang.hmcl.ui.instances.Instances; import org.jackhuang.hmcl.ui.wizard.Navigation; import org.jackhuang.hmcl.ui.wizard.WizardController; @@ -144,11 +143,10 @@ private static Supplier loadVersionFor(Supplier nodeSuppl } public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) { - if (instanceId == null) { - instanceId = repository.getSelectedInstance(); - } - - Path runDirectory = instanceId != null && repository.hasInstance(instanceId) ? repository.getRunDirectory(instanceId) : repository.getBaseDirectory(); + @Nullable HMCLGameInstance instance = instanceId != null + ? repository.findInstance(instanceId) + : repository.getSelectedInstance(); + Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory(); Set existingFiles; @@ -326,7 +324,7 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { return builder.buildAsync().whenComplete(any -> { repository.refresh(); repository.applyDefaultIsolationSetting(instanceId); - }).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId)); + }).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId))); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java index 94f650223bd..aace4d4ea33 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java @@ -124,10 +124,10 @@ private Task finishModpackInstallingAsync(SettingsMap settings) { } else { if (serverModpackManifest != null) { return ModpackHelper.getInstallTask(repository, serverModpackManifest, instanceId, modpack) - .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId)); + .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId))); } else { return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl) - .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(instanceId)); + .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId))); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java index b85cc862457..da6a9582a18 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java @@ -125,7 +125,8 @@ public void loadInstance(HMCLGameInstance.Optional instance) { instances.setAll(repository.getDisplayInstances() .map(DefaultGameInstance::getId) .toList()); - selectedInstance.set(repository.getSelectedInstance()); + @Nullable HMCLGameInstance repositorySelection = repository.getSelectedInstance(); + selectedInstance.set(repositorySelection != null ? repositorySelection.getId() : null); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java index bf9a3e6601d..c6265aff550 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java @@ -19,9 +19,7 @@ import javafx.geometry.Pos; import org.jackhuang.hmcl.event.Event; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; -import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.setting.GameInstanceIconType; @@ -29,6 +27,7 @@ import org.jackhuang.hmcl.ui.WeakListenerHolder; import org.jackhuang.hmcl.ui.construct.AdvancedListItem; import org.jackhuang.hmcl.ui.construct.ImageContainer; +import org.jetbrains.annotations.Nullable; import java.util.function.Consumer; @@ -37,12 +36,9 @@ public class GameAdvancedListItem extends AdvancedListItem { private final ImageContainer imageContainer; private final WeakListenerHolder holder = new WeakListenerHolder(); - private HMCLGameRepository repository; + private @Nullable HMCLGameRepository repository; @SuppressWarnings("unused") - private Consumer onInstanceIconChangedListener; - - @SuppressWarnings({"unused", "FieldCanBeLocal"}) - private Consumer onRefreshedInstancesListener; + private @Nullable Consumer onInstanceIconChangedListener; public GameAdvancedListItem() { this.imageContainer = new ImageContainer(LEFT_GRAPHIC_SIZE); @@ -53,27 +49,17 @@ public GameAdvancedListItem() { holder.add(FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), this::loadInstance)); } - private void loadInstance(GameInstanceID instanceId) { + private void loadInstance(@Nullable HMCLGameInstance instance) { if (GameDirectoryManager.getSelectedRepository() != repository) { repository = GameDirectoryManager.getSelectedRepository(); - if (repository != null) { - onInstanceIconChangedListener = repository.onInstanceIconChanged.registerWeak(event -> { - FXUtils.runInFX(() -> loadInstance(repository.getSelectedInstance())); - }); - if (!repository.isLoaded()) { - onRefreshedInstancesListener = EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class) - .registerWeak(event -> FXUtils.runInFX(() -> loadInstance(repository.getSelectedInstance()))); - return; - } - } + onInstanceIconChangedListener = repository.onInstanceIconChanged.registerWeak(event -> + FXUtils.runInFX(() -> loadInstance(repository.getSelectedInstance()))); } - if (instanceId != null && repository != null) { - if (repository.hasInstance(instanceId)) { - setTitle(i18n("instance.manage.manage")); - setSubtitle(instanceId.toString()); - imageContainer.setImage(repository.getInstanceIconImage(instanceId)); - return; - } + if (instance != null) { + setTitle(i18n("instance.manage.manage")); + setSubtitle(instance.getId().toString()); + imageContainer.setImage(instance.getRepository().getInstanceIconImage(instance.getId())); + return; } setTitle(i18n("instance.empty")); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java index 7a7d9ecc5d6..d69aa6b841c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListCell.java @@ -29,7 +29,6 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Region; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.*; @@ -70,7 +69,7 @@ public void fire() { fireEvent(new ActionEvent()); GameListItem item = GameListCell.this.getItem(); if (item != null) { - item.getRepository().setSelectedInstance(new GameInstanceID(item.getId())); + item.getRepository().setSelectedInstance(item.getGameInstance()); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java index 44452618255..b8b2a090b83 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java @@ -25,8 +25,7 @@ import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.GameDirectoryManager; - -import java.util.Objects; +import org.jetbrains.annotations.Nullable; public class GameListItem extends GameItem { private final boolean isModpack; @@ -40,7 +39,8 @@ public GameListItem(HMCLGameInstance gameInstance) { selected.bind(Bindings.createBooleanBinding( () -> { if (repository.getGameDirectory() != GameDirectoryManager.getSelectedGameDirectory()) return false; - return Objects.equals(repository.getSelectedInstance(), instanceId); + @Nullable HMCLGameInstance selectedInstance = repository.getSelectedInstance(); + return selectedInstance != null && selectedInstance.getId().equals(instanceId); }, GameDirectoryManager.selectedGameDirectoryProperty(), GameDirectoryManager.selectedInstanceProperty())); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java index 8ed14395626..73224960f94 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java @@ -33,7 +33,6 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.ui.FXUtils; @@ -140,7 +139,7 @@ public Cell(ListView listView) { FXUtils.onClicked(rootPane, () -> { GameItem item = getItem(); if (item != null) { - item.getRepository().setSelectedInstance(new GameInstanceID(item.getId())); + item.getRepository().setSelectedInstance(item.getGameInstance()); if (getScene().getWindow() instanceof JFXPopup popup) popup.hide(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java index ae3868f3f53..b97821079af 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java @@ -48,6 +48,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.net.URI; @@ -152,7 +153,7 @@ public static CompletableFuture renameInstance(HMCLGameInstance gameInst repository.refreshAsync() .thenRunAsync(Schedulers.javafx(), () -> { if (repository.hasInstance(newInstanceId)) { - repository.setSelectedInstance(newInstanceId); + repository.setSelectedInstance(repository.getInstance(newInstanceId)); } }).start(); } else { @@ -203,7 +204,7 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { .thenRunAsync(repository::refresh) .whenComplete(Schedulers.javafx(), (exception) -> { if (exception == null) { - repository.setSelectedInstance(new GameInstanceID(result)); + repository.setSelectedInstance(repository.getInstance(instanceId)); } else { Controllers.dialog( DownloadProviders.localizeErrorMessage(exception), i18n("install.failed"), MessageDialogPane.MessageType.ERROR); @@ -310,8 +311,27 @@ private static String getDefaultScriptExtension() { }; } + /// Launches the given instance after ensuring that an account is selected. + /// + /// If `gameInstance` is `null`, an error dialog is shown and no account selection or launch is + /// attempted. + /// + /// @param gameInstance the instance to launch, or `null` when no instance is available + /// @param injecters callbacks that configure the launcher before launch @SafeVarargs - public static void launch(HMCLGameInstance gameInstance, Consumer... injecters) { + public static void launch(@Nullable HMCLGameInstance gameInstance, Consumer... injecters) { + if (gameInstance == null) { + JFXButton gotoDownload = new JFXButton(i18n("instance.empty.launch.goto_download")); + gotoDownload.getStyleClass().add("dialog-accept"); + gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage())); + + Controllers.confirmAction(i18n("instance.empty.launch"), i18n("launch.failed"), + MessageDialogPane.MessageType.ERROR, + gotoDownload, + null); + return; + } + ensureSelectedAccount(account -> { LauncherHelper launcherHelper = new LauncherHelper(gameInstance, account); for (Consumer injecter : injecters) { @@ -321,15 +341,6 @@ public static void launch(HMCLGameInstance gameInstance, Consumer... injecters) { - HMCLGameInstance gameInstance = resolveLaunchInstance(repository, instanceId); - if (gameInstance != null) { - launch(gameInstance, injecters); - } - } - public static void testGame(HMCLGameInstance gameInstance) { launch(gameInstance, LauncherHelper::setTestMode); } @@ -344,36 +355,6 @@ public static void generateLaunchScriptForQuickEnterWorld(HMCLGameInstance gameI launcherHelper.setQuickPlayOption(new QuickPlayOption.SinglePlayer(worldFolderName))); } - private static HMCLGameInstance resolveLaunchInstance(HMCLGameRepository repository, GameInstanceID instanceId) { - if (!checkVersionForLaunching(repository, instanceId)) { - return null; - } - return repository.findInstance(instanceId); - } - - private static boolean checkVersionForLaunching(HMCLGameRepository repository, GameInstanceID instanceId) { - boolean unavailable; - if (instanceId == null || !repository.isLoaded()) { - unavailable = true; - } else { - unavailable = !repository.hasInstance(instanceId); - } - - if (unavailable) { - JFXButton gotoDownload = new JFXButton(i18n("instance.empty.launch.goto_download")); - gotoDownload.getStyleClass().add("dialog-accept"); - gotoDownload.setOnAction(e -> Controllers.navigate(Controllers.getDownloadPage())); - - Controllers.confirmAction(i18n("instance.empty.launch"), i18n("launch.failed"), - MessageDialogPane.MessageType.ERROR, - gotoDownload, - null); - return false; - } else { - return true; - } - } - private static void ensureSelectedAccount(Consumer action) { Account account = Accounts.getSelectedAccount(); if (SettingsManager.isNewlyCreated() && !AuthlibInjectorServers.getServers().isEmpty() && @@ -416,10 +397,4 @@ public static void modifyGameSettings(HMCLGameInstance gameInstance) { Controllers.navigate(Controllers.getGameInstancePage()); } - public static void modifyGameSettings(HMCLGameRepository repository, GameInstanceID instanceId) { - HMCLGameInstance gameInstance = repository.findInstance(instanceId); - if (gameInstance != null) { - modifyGameSettings(gameInstance); - } - } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java index 72d5aff3710..5237ca15dd3 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 @@ -48,6 +48,7 @@ import org.jackhuang.hmcl.download.VersionList; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameDirectory; @@ -94,7 +95,7 @@ public final class MainPage extends StackPane implements DecoratorPage { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(); - private final ObjectProperty<@Nullable GameInstanceID> currentGame = new SimpleObjectProperty<>(this, "currentGame"); + private final ObjectProperty<@Nullable HMCLGameInstance> currentGame = new SimpleObjectProperty<>(this, "currentGame"); private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate"); private final BooleanProperty showUpdateDialog = new SimpleBooleanProperty(this, "showUpdateDialog"); private final ObjectProperty latestVersion = new SimpleObjectProperty<>(this, "latestVersion"); @@ -212,9 +213,10 @@ public final class MainPage extends StackPane implements DecoratorPage { HBox launchPane = new HBox(); launchPane.getStyleClass().add("launch-pane"); FXUtils.onScroll(launchPane, versions, list -> { - GameInstanceID currentId = getCurrentGame(); + @Nullable HMCLGameInstance currentGame = getCurrentGame(); + @Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null; return Lang.indexWhere(list, instance -> instance.id().equals(currentId)); - }, it -> repository.setSelectedInstance(it.id())); + }, it -> repository.setSelectedInstance(repository.getInstance(it.id()))); StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT); { @@ -233,7 +235,7 @@ public final class MainPage extends StackPane implements DecoratorPage { private Tooltip tooltip; @Override - public void accept(@Nullable GameInstanceID currentGame) { + public void accept(@Nullable HMCLGameInstance currentGame) { if (currentGame == null) { launchLabel.setText(i18n("instance.launch.empty")); currentLabel.setText(null); @@ -244,7 +246,7 @@ public void accept(@Nullable GameInstanceID currentGame) { FXUtils.installFastTooltip(launchButton, tooltip); } else { launchLabel.setText(i18n("instance.launch")); - currentLabel.setText(currentGame.toString()); + currentLabel.setText(currentGame.getId().toString()); graphic.getChildren().setAll(launchLabel, currentLabel); FXUtils.setOnActionWithCooldown(launchButton, MainPage.this::launch); if (tooltip != null) @@ -342,7 +344,7 @@ private void doAnimation(boolean show) { private void launch() { HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); - Instances.launch(repository, repository.getSelectedInstance()); + Instances.launch(repository.getSelectedInstance()); } private void launchNoGame() { @@ -374,7 +376,8 @@ private void launchNoGame() { .whenComplete(any -> GameDirectoryManager.getSelectedRepository().refresh()) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { - GameDirectoryManager.getSelectedRepository().setSelectedInstance(instanceHolder.value); + HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); + repository.setSelectedInstance(repository.getInstance(instanceHolder.value)); launch(); } else if (exception instanceof CancellationException) { Controllers.showToast(i18n("message.cancelled")); @@ -414,15 +417,24 @@ public HMCLGameRepository getRepository() { return repository; } - public GameInstanceID getCurrentGame() { + /// Returns the instance shown by the launch controls. + /// + /// @return the current instance, or `null` when no instance is selected + public @Nullable HMCLGameInstance getCurrentGame() { return currentGame.get(); } - public ObjectProperty<@Nullable GameInstanceID> currentGameProperty() { + /// Returns the property for the instance shown by the launch controls. + /// + /// @return the current-instance property + public ObjectProperty<@Nullable HMCLGameInstance> currentGameProperty() { return currentGame; } - public void setCurrentGame(@Nullable GameInstanceID currentGame) { + /// Sets the instance shown by the launch controls. + /// + /// @param currentGame the instance to show, or `null` to show the empty state + public void setCurrentGame(@Nullable HMCLGameInstance currentGame) { this.currentGame.set(currentGame); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java index 97478cf94f5..f0c4abaf092 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java @@ -25,6 +25,7 @@ import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.setting.Accounts; @@ -58,6 +59,7 @@ import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.*; import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jetbrains.annotations.Nullable; import java.nio.file.Files; import java.nio.file.Path; @@ -155,17 +157,21 @@ protected Skin(RootPage control) { // second item in left sidebar GameAdvancedListItem gameListItem = new GameAdvancedListItem(); gameListItem.setOnAction(e -> { - GameInstanceID instanceId = GameDirectoryManager.getSelectedRepository().getSelectedInstance(); - if (instanceId == null) { + @Nullable HMCLGameInstance instance = GameDirectoryManager.getSelectedRepository().getSelectedInstance(); + if (instance == null) { Controllers.navigate(Controllers.getGameListPage()); } else { - Instances.modifyGameSettings(GameDirectoryManager.getSelectedRepository(), instanceId); + Instances.modifyGameSettings(instance); } }); FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getVersions(), list -> { - GameInstanceID currentId = getSkinnable().getMainPage().getCurrentGame(); + @Nullable HMCLGameInstance currentGame = getSkinnable().getMainPage().getCurrentGame(); + @Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null; return Lang.indexWhere(list, instance -> instance.id().equals(currentId)); - }, it -> getSkinnable().getMainPage().getRepository().setSelectedInstance(it.id())); + }, it -> { + HMCLGameRepository repository = getSkinnable().getMainPage().getRepository(); + repository.setSelectedInstance(repository.getInstance(it.id())); + }); if (AnimationUtils.isAnimationEnabled()) { FXUtils.prepareOnMouseEnter(gameListItem, Controllers::prepareGameInstancePage); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java index d8478cfa11a..6a513447eba 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaControllerPage.java @@ -221,7 +221,7 @@ public TerracottaControllerPage() { MessageDialogPane.MessageType.QUESTION ).addAction(i18n("instance.launch"), () -> { var repository = GameDirectoryManager.getSelectedRepository(); - Instances.launch(repository, repository.getSelectedInstance(), launcherHelper -> { + Instances.launch(repository.getSelectedInstance(), launcherHelper -> { launcherHelper.setKeep(); launcherHelper.setDisableOfflineSkin(); }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java index e4ea34fda53..be2a2a4accb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java @@ -26,6 +26,7 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.terracotta.TerracottaMetadata; import org.jackhuang.hmcl.ui.Controllers; @@ -41,6 +42,7 @@ import org.jackhuang.hmcl.ui.instances.GameListPopupMenu; import org.jackhuang.hmcl.ui.instances.Instances; import org.jackhuang.hmcl.util.Lang; +import org.jetbrains.annotations.Nullable; import static org.jackhuang.hmcl.setting.SettingsManager.userState; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -54,7 +56,7 @@ public class TerracottaPage extends DecoratorAnimatedPage implements DecoratorPa private final TransitionPane transitionPane = new TransitionPane(); @SuppressWarnings("unused") - private ChangeListener instanceChangeListenerHolder; + private @Nullable ChangeListener<@Nullable HMCLGameInstance> instanceChangeListenerHolder; public TerracottaPage() { statusPage.setNodeSupplier(TerracottaControllerPage::new); @@ -79,20 +81,21 @@ public TerracottaPage() { .add(accountListItem) .addNavigationDrawerItem(i18n("instance.launch"), SVG.ROCKET_LAUNCH, () -> { var repository = GameDirectoryManager.getSelectedRepository(); - Instances.launch(repository, repository.getSelectedInstance(), launcherHelper -> { + Instances.launch(repository.getSelectedInstance(), launcherHelper -> { launcherHelper.setKeep(); launcherHelper.setDisableOfflineSkin(); }); }, item -> { instanceChangeListenerHolder = FXUtils.onWeakChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), - instanceName -> item.setSubtitle(instanceName != null ? instanceName.toString() : i18n("instance.empty")) + instance -> item.setSubtitle(instance != null ? instance.getId().toString() : i18n("instance.empty")) ); MainPage mainPage = Controllers.getRootPage().getMainPage(); FXUtils.onScroll(item, mainPage.getVersions(), list -> { - GameInstanceID currentId = mainPage.getCurrentGame(); + @Nullable HMCLGameInstance currentGame = mainPage.getCurrentGame(); + @Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null; return Lang.indexWhere(list, instance -> instance.id().equals(currentId)); - }, it -> mainPage.getRepository().setSelectedInstance(it.id())); + }, it -> mainPage.getRepository().setSelectedInstance(mainPage.getRepository().getInstance(it.id()))); FXUtils.onSecondaryButtonClicked(item, () -> GameListPopupMenu.show(item, JFXPopup.PopupVPosition.BOTTOM, 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 f1db2ae278f..7258cdaae17 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -23,9 +23,12 @@ import com.google.gson.JsonParseException; import com.google.gson.JsonParser; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyObjectProperty; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.PortablePath; @@ -33,6 +36,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.i18n.LocalizedText; import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -41,6 +45,7 @@ import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.List; import java.util.Map; import java.util.Objects; @@ -660,6 +665,63 @@ public void newInstanceAfterMigrationDoesNotUseLegacyGameDirectoryParent(@TempDi } } + /// Tests that repository selection exposes the current snapshot member while persisting its ID. + @Test + public void selectedInstanceTracksRepositorySnapshots(@TempDir Path tempDirectory) + throws Exception { + GameDirectory gameDirectory = new GameDirectory( + GameDirectoryID.generate(), + LocalizedText.plain("Dev"), + PortablePath.of(tempDirectory.toString())); + GameDirectories localDirectories = new GameDirectories(); + localDirectories.getGameDirectories().add(gameDirectory); + GameDirectories userDirectories = new GameDirectories(); + + try (GameDirectoryEnvironment ignored = + new GameDirectoryEnvironment(localDirectories, userDirectories)) { + HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); + GameInstanceID firstId = new GameInstanceID("1.20.1"); + GameInstanceID secondId = new GameInstanceID("1.21.1"); + GameInstanceManifest firstManifest = new GameInstanceManifest(firstId); + repository.saveAsync(firstManifest).run(); + repository.saveAsync(new GameInstanceManifest(secondId)).run(); + + ReadOnlyObjectProperty<@Nullable HMCLGameInstance> selectedInstance = + repository.selectedInstanceProperty(); + List observedSelections = new ArrayList<>(); + selectedInstance.addListener((observable, oldValue, newValue) -> { + if (newValue != null) { + observedSelections.add(newValue); + } + }); + HMCLGameInstance firstInstance = repository.getInstance(firstId); + repository.setSelectedInstance(firstInstance); + + assertSame(firstInstance, selectedInstance.get()); + assertSame(firstInstance, observedSelections.getLast()); + assertEquals(firstId, settings().getSelectedInstance(gameDirectory.getId())); + + repository.saveAsync(firstManifest).run(); + HMCLGameInstance refreshedFirstInstance = repository.getInstance(firstId); + assertNotSame(firstInstance, refreshedFirstInstance); + assertSame(refreshedFirstInstance, selectedInstance.get()); + assertSame(refreshedFirstInstance, observedSelections.getLast()); + repository.setSelectedInstance(firstInstance); + assertSame(refreshedFirstInstance, selectedInstance.get()); + + settings().setSelectedInstance(gameDirectory.getId(), secondId); + assertSame(repository.getInstance(secondId), selectedInstance.get()); + + settings().setSelectedInstance(gameDirectory.getId(), new GameInstanceID("missing")); + assertNull(selectedInstance.get()); + repository.refreshSelectedInstance(); + HMCLGameInstance fallbackInstance = assertDoesNotThrow(() -> + Objects.requireNonNull(repository.getSelectedInstance())); + assertSame(repository.getInstance(fallbackInstance.getId()), fallbackInstance); + assertEquals(fallbackInstance.getId(), settings().getSelectedInstance(gameDirectory.getId())); + } + } + /// Temporary static state override for game directory tests. private static final class GameDirectoryEnvironment implements AutoCloseable { /// The reflected SettingsManager local game directories field. From e4d7842da58873c4dcea32fa3abc207186be978c Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:19:58 +0800 Subject: [PATCH 055/199] Remove unused methods for instance game settings management in HMCLGameRepository --- .../jackhuang/hmcl/game/HMCLGameRepository.java | 16 ---------------- 1 file changed, 16 deletions(-) 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 2db49cab47c..2f6e4703800 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -361,21 +361,6 @@ public GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID inst return setting; } - /// Returns whether the instance-specific game settings file cannot be overwritten safely. - /// - /// @param instanceId the instance ID - /// @return whether the instance settings are loaded in read-only mode - public boolean isInstanceGameSettingsReadOnly(GameInstanceID instanceId) { - return resolveInstance(instanceId).isSettingsReadOnly(); - } - - /// Backs up and overwrites the instance-specific game settings file with the currently loaded settings. - /// - /// @param instanceId the instance ID - public void forceOverwriteInstanceGameSettings(GameInstanceID instanceId) { - resolveInstance(instanceId).forceOverwriteSettings(); - } - /// Returns the explicit parent preset of the instance, falling back to the default preset. public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance instance) { @Nullable GameSettingsPresetID parent = instance != null ? instance.parentProperty().getValue() : null; @@ -664,7 +649,6 @@ public boolean unmarkInstanceLaunchedAbnormally(GameInstanceID instanceId) { private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}"; - // These instance ids are forbidden because they may conflict with modpack configuration filenames private static final Set FORBIDDEN_INSTANCE_IDS = Set.of("modpack", "minecraftinstance", "manifest"); From 89f625847430cc522af39840d2a7c79d400e2a62 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:22:32 +0800 Subject: [PATCH 056/199] refactor(modpack): Remove obsolete MCBBS remote install task --- .../mcbbs/McbbsModpackRemoteInstallTask.java | 97 ------------------- 1 file changed, 97 deletions(-) delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java deleted file mode 100644 index d7b54e1d2df..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackRemoteInstallTask.java +++ /dev/null @@ -1,97 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2026 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.modpack.mcbbs; - -import com.google.gson.JsonParseException; -import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.GameBuilder; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.modpack.ModpackConfiguration; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.gson.JsonUtils; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -public class McbbsModpackRemoteInstallTask extends Task { - - private final GameInstanceID instanceId; - private final DefaultDependencyManager dependency; - private final DefaultGameRepository repository; - private final List> dependencies = new ArrayList<>(1); - private final List> dependents = new ArrayList<>(1); - private final McbbsModpackManifest manifest; - - public McbbsModpackRemoteInstallTask(DefaultDependencyManager dependencyManager, McbbsModpackManifest manifest, GameInstanceID instanceId) { - this.instanceId = instanceId; - this.dependency = dependencyManager; - this.repository = dependencyManager.getGameRepository(); - this.manifest = manifest; - - Path json = repository.getModpackConfiguration(instanceId); - if (repository.hasInstance(instanceId) && Files.notExists(json)) - throw new IllegalArgumentException("Instance " + instanceId + " already exists."); - - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId); - for (McbbsModpackManifest.Addon addon : manifest.getAddons()) { - builder.version(addon.getId(), addon.getVersion()); - } - - dependents.add(builder.buildAsync()); - onDone().register(event -> { - if (event.isFailed()) - repository.removeInstanceFromDisk(instanceId); - }); - - ModpackConfiguration config; - try { - if (Files.exists(json)) { - config = JsonUtils.fromJsonFile(json, ModpackConfiguration.typeOf(McbbsModpackManifest.class)); - - if (!MODPACK_TYPE.equals(config.getType())) - throw new IllegalArgumentException("Instance " + instanceId + " is not a Mcbbs modpack. Cannot update this instance."); - } - } catch (JsonParseException | IOException ignore) { - } - } - - @Override - public List> getDependents() { - return dependents; - } - - @Override - public List> getDependencies() { - return dependencies; - } - - @Override - public void execute() throws Exception { - dependencies.add(new McbbsModpackCompletionTask( - dependency, - repository.getInstance(instanceId), - new ModpackConfiguration<>(manifest, MODPACK_TYPE, manifest.getName(), manifest.getVersion(), Collections.emptyList()))); - } - - public static final String MODPACK_TYPE = "Server"; -} From b60b53885ca1aec3d7418f1ab7cde3e225198c00 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:26:50 +0800 Subject: [PATCH 057/199] refactor(HMCLGameRepository): Update instance ID conflict check to use HMCLGameInstance --- .../main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 2f6e4703800..4cba5bf24a3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -677,8 +677,8 @@ public boolean instanceIdConflicts(String instanceId) { public boolean instanceIdConflicts(GameInstanceID id) { if (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS) { // on Windows, filenames are case-insensitive - for (GameInstanceManifest manifest : getInstanceManifests()) { - if (manifest.id().toString().equalsIgnoreCase(id.toString())) { + for (HMCLGameInstance instance : getSnapshot().getInstances()) { + if (instance.getId().toString().equalsIgnoreCase(id.toString())) { return true; } } From 7fd77dff4b5b41e6d6259734621e550edde8fcb4 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:28:34 +0800 Subject: [PATCH 058/199] refactor(DefaultGameRepository, HMCLGameRepository): streamline refresh logic and remove obsolete refreshImpl method --- .../jackhuang/hmcl/game/HMCLGameRepository.java | 15 --------------- .../hmcl/game/DefaultGameRepository.java | 9 +++------ 2 files changed, 3 insertions(+), 21 deletions(-) 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 4cba5bf24a3..396dc3a48e5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -247,21 +247,6 @@ public Stream getDisplayInstances() { .thenComparing(instance -> VersionNumber.asVersion(instance.getId().id()))); } - @Override - protected void refreshImpl() { - super.refreshImpl(); - - try { - Path file = getBaseDirectory().resolve("launcher_profiles.json"); - if (!Files.exists(file) && !getInstanceManifests().isEmpty()) { - Files.createDirectories(file.getParent()); - Files.writeString(file, PROFILE); - } - } catch (IOException ex) { - LOG.warning("Unable to create launcher_profiles.json, Forge/LiteLoader installer will not work.", ex); - } - } - public void changeDirectory(Path newDirectory) { setBaseDirectory(newDirectory); refreshAsync().start(); 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 8c654728b59..6b81111cb01 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -188,12 +188,6 @@ public boolean isLoaded() { @Override public void refresh() { - refreshImpl(); - loaded = true; - EventBus.EVENT_BUS.fireEvent(new RefreshedGameInstancesEvent(this)); - } - - protected void refreshImpl() { DefaultGameRepositorySnapshot newSnapshot = createSnapshot(getSnapshot().getLayout()); DefaultGameRepositoryLayout layout = newSnapshot.getLayout(); @@ -242,6 +236,9 @@ protected void refreshImpl() { newSnapshot.clear(); newSnapshot.putAll(loadedInstances); publishSnapshot(newSnapshot); + + loaded = true; + EventBus.EVENT_BUS.fireEvent(new RefreshedGameInstancesEvent(this)); } /// Loads one instance directory without renaming on-disk JSON or jar files. From 032e7d829fb0ffdbe94d319acdeb06fe00b4f97b Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:38:44 +0800 Subject: [PATCH 059/199] refactor(DefaultGameRepository, GameInstancePage, RootPage, GameDirectoryManager): add refresh count tracking and improve repository refresh handling --- .../hmcl/setting/GameDirectoryManager.java | 31 +++++---- .../hmcl/ui/instances/GameInstancePage.java | 36 ++++++++-- .../org/jackhuang/hmcl/ui/main/RootPage.java | 9 +-- .../event/RefreshedGameInstancesEvent.java | 40 ----------- .../hmcl/game/DefaultGameRepository.java | 68 ++++++++++++++++--- 5 files changed, 106 insertions(+), 78 deletions(-) delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshedGameInstancesEvent.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java index 900e1f567ba..c27b6485b26 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java @@ -22,8 +22,6 @@ import javafx.collections.FXCollections; import javafx.collections.ObservableList; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.util.PortablePath; @@ -44,7 +42,6 @@ import java.util.function.Consumer; import static org.jackhuang.hmcl.setting.SettingsManager.*; -import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; /// Manages the merged runtime view of local and user game directories. @@ -149,6 +146,10 @@ private static boolean isGameDirectoryPath(GameDirectory gameDirectory, Portable private static final ChangeListener<@Nullable HMCLGameInstance> selectedRepositoryInstanceListener = (observable, oldValue, newValue) -> selectedInstance.set(newValue); + /// Handles completion of a full refresh by the selected repository. + private static final ChangeListener selectedRepositoryRefreshListener = + (observable, oldValue, newValue) -> onSelectedRepositoryRefreshed(); + /// Initializes game directory state from the stores loaded by [SettingsManager]. /// /// This method creates the built-in local and user-home game directories when required, rebuilds @@ -203,25 +204,29 @@ public static void init() { @Nullable HMCLGameRepository oldRepository = selectedRepository.get(); if (oldRepository != null) { oldRepository.selectedInstanceProperty().removeListener(selectedRepositoryInstanceListener); + oldRepository.refreshCountProperty().removeListener(selectedRepositoryRefreshListener); } HMCLGameRepository repository = getOrCreateRepository(newValue); selectedRepository.set(repository); selectedInstance.set(repository.getSelectedInstance()); repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener); + repository.refreshCountProperty().addListener(selectedRepositoryRefreshListener); repository.refreshAsync().start(); }); selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0)); + } - EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> { - runInFX(() -> { - @Nullable HMCLGameRepository repository = selectedRepository.get(); - if (repository != null && repository == event.getSource()) { - repository.refreshSelectedInstance(); - for (Consumer listener : versionsListeners) - listener.accept(repository); - } - }); - }); + /// Restores selection and notifies consumers after the selected repository finishes refreshing. + private static void onSelectedRepositoryRefreshed() { + @Nullable HMCLGameRepository repository = selectedRepository.get(); + if (repository == null) { + return; + } + + repository.refreshSelectedInstance(); + for (Consumer listener : versionsListeners) { + listener.accept(repository); + } } /// Creates the built-in game directories only when no game directory exists. diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index 0127973df15..efbed3d04c9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -21,14 +21,11 @@ import javafx.application.Platform; import javafx.beans.binding.Bindings; import javafx.beans.property.*; +import javafx.beans.value.ChangeListener; import javafx.event.Event; import javafx.event.EventType; -import javafx.scene.Node; import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.EventPriority; -import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; @@ -68,7 +65,15 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator new SimpleObjectProperty<>(this, "instance"); private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); - private GameInstanceID preferredInstanceId = null; + /// Refreshes the page context when its repository finishes a full refresh. + private final ChangeListener repositoryRefreshListener = + (observable, oldValue, newValue) -> checkSelectedInstance(); + + /// Repository currently observed for full-refresh completion. + private @Nullable HMCLGameRepository observedRepository; + + /// Last concrete instance displayed by this page. + private @Nullable GameInstanceID preferredInstanceId; public static class WorkingDirChangedEvent extends Event { public static final EventType EVENT_TYPE = new EventType<>(Event.ANY, "WORKING_DIR_CHANGED"); @@ -100,10 +105,9 @@ public GameInstancePage() { } }); - listenerHolder.add(EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class).registerWeak(event -> checkSelectedInstance(), EventPriority.HIGHEST)); - // Page chrome that depends on the current instance. listenerHolder.add(FXUtils.onWeakChange(instance, current -> { + observeRepository(current); if (current == null) { return; } @@ -116,6 +120,24 @@ public GameInstancePage() { })); } + /// Observes refresh completion for the repository associated with the current page context. + /// + /// @param current the current page context, or `null` when the page has no context + private void observeRepository(HMCLGameInstance.@Nullable Optional current) { + @Nullable HMCLGameRepository repository = current != null ? current.repository() : null; + if (repository == observedRepository) { + return; + } + + if (observedRepository != null) { + observedRepository.refreshCountProperty().removeListener(repositoryRefreshListener); + } + observedRepository = repository; + if (repository != null) { + repository.refreshCountProperty().addListener(repositoryRefreshListener); + } + } + /// Returns the current instance context for this page and its tabs. /// /// Child tabs subscribe to this property and reload when it changes. The page only publishes diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java index f0c4abaf092..74ca4e945d2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java @@ -21,8 +21,6 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.scene.layout.Region; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.event.EventBus; -import org.jackhuang.hmcl.event.RefreshedGameInstancesEvent; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameInstance; @@ -77,12 +75,7 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage { private MainPage mainPage = null; public RootPage() { - EventBus.EVENT_BUS.channel(RefreshedGameInstancesEvent.class) - .register(event -> onRefreshedVersions((HMCLGameRepository) event.getSource())); - - HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); - if (repository.isLoaded()) - onRefreshedVersions(GameDirectoryManager.getSelectedRepository()); + GameDirectoryManager.registerVersionsListener(this::onRefreshedVersions); getStyleClass().remove("gray-background"); getLeft().getStyleClass().add("gray-background"); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshedGameInstancesEvent.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshedGameInstancesEvent.java deleted file mode 100644 index b7e6bccaa24..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/event/RefreshedGameInstancesEvent.java +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.event; - -import org.jackhuang.hmcl.game.GameRepository; - -/** - * This event gets fired when all the versions in .minecraft folder are loaded. - *
- * This event is fired on the {@link org.jackhuang.hmcl.event.EventBus#EVENT_BUS} - * - * @author huangyuhui - */ -public final class RefreshedGameInstancesEvent extends Event { - - /** - * Constructor. - * - * @param source {@link GameRepository} - */ - public RefreshedGameInstancesEvent(Object source) { - super(source); - } - -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 6b81111cb01..19d3f46d319 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -20,12 +20,13 @@ import com.google.gson.JsonParseException; import javafx.application.Platform; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.ReadOnlyLongProperty; +import javafx.beans.property.ReadOnlyLongWrapper; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; import org.jackhuang.hmcl.download.MaintainTask; -import org.jackhuang.hmcl.event.*; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; @@ -99,12 +100,20 @@ private static boolean hasClassicInstance(Path baseDirectory) { /// Published snapshot, always updated on the JavaFX application thread when the toolkit is live. private final ObjectProperty snapshot; + /// Number of completed full refreshes. + private final ReadOnlyLongWrapper refreshCount; + + /// Whether at least one full refresh has completed since the base directory was set. private volatile boolean loaded; + /// Creates a repository rooted at the given directory with an empty initial snapshot. + /// + /// @param baseDirectory the initial repository base directory public DefaultGameRepository(Path baseDirectory) { DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); initial.seal(); this.snapshot = new SimpleObjectProperty<>(initial); + this.refreshCount = new ReadOnlyLongWrapper(this, "refreshCount"); } /// Creates the repository layout rooted at the given directory. @@ -138,6 +147,25 @@ public final ReadOnlyObjectProperty snapshotPrope return snapshot; } + /// Returns the number of completed full repository refreshes. + /// + /// The property is incremented after a refreshed snapshot is published and [#isLoaded()] becomes + /// `true`. When the JavaFX toolkit is running, listeners are notified on its application thread. + /// Snapshot publications caused by operations such as saving or renaming an instance do not + /// increment this property. + /// + /// @return the read-only refresh-count property + public final ReadOnlyLongProperty refreshCountProperty() { + return refreshCount.getReadOnlyProperty(); + } + + /// Returns the number of completed full repository refreshes. + /// + /// @return the completed refresh count + public final long getRefreshCount() { + return refreshCount.get(); + } + /// Seals `newSnapshot` if needed and publishes it as the current repository snapshot. /// /// When the JavaFX toolkit is running, the property is updated on the JavaFX application thread @@ -153,27 +181,47 @@ protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { /// Sets [#snapshot] on the JavaFX application thread when possible. private void setSnapshotOnFxThread(DefaultGameRepositorySnapshot newSnapshot) { + runOnFxThreadAndWait(() -> snapshot.set(newSnapshot)); + } + + /// Runs an action on the JavaFX application thread and waits for its completion. + /// + /// The action runs on the calling thread when the JavaFX toolkit has not been initialized. + /// Interruptions are restored after a queued JavaFX action completes. + /// + /// @param action the action to run + private static void runOnFxThreadAndWait(Runnable action) { if (Platform.isFxApplicationThread()) { - snapshot.set(newSnapshot); + action.run(); return; } + CountDownLatch completed = new CountDownLatch(1); try { - CountDownLatch published = new CountDownLatch(1); Platform.runLater(() -> { try { - snapshot.set(newSnapshot); + action.run(); } finally { - published.countDown(); + completed.countDown(); } }); - published.await(); } catch (IllegalStateException ignored) { // JavaFX toolkit is not initialized (for example in headless unit tests). - snapshot.set(newSnapshot); - } catch (InterruptedException e) { + action.run(); + return; + } + + boolean interrupted = false; + while (true) { + try { + completed.await(); + break; + } catch (InterruptedException e) { + interrupted = true; + } + } + if (interrupted) { Thread.currentThread().interrupt(); - snapshot.set(newSnapshot); } } @@ -238,7 +286,7 @@ public void refresh() { publishSnapshot(newSnapshot); loaded = true; - EventBus.EVENT_BUS.fireEvent(new RefreshedGameInstancesEvent(this)); + runOnFxThreadAndWait(() -> refreshCount.set(refreshCount.get() + 1)); } /// Loads one instance directory without renaming on-disk JSON or jar files. From 2b126cf3863563a9877f59ae44a40752b6d8875d Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:47:07 +0800 Subject: [PATCH 060/199] refactor(MainPage, GameListPopupMenu, RootPage, TerracottaPage): update instance handling to use HMCLGameInstance and improve repository snapshot management --- .../hmcl/ui/instances/GameListPopupMenu.java | 31 ++++++--- .../org/jackhuang/hmcl/ui/main/MainPage.java | 65 +++++++++++++------ .../org/jackhuang/hmcl/ui/main/RootPage.java | 33 ++-------- .../hmcl/ui/terracotta/TerracottaPage.java | 8 +-- 4 files changed, 74 insertions(+), 63 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java index 73224960f94..29e26bd7fe0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListPopupMenu.java @@ -33,8 +33,7 @@ import javafx.scene.layout.BorderPane; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.ui.construct.ImageContainer; import org.jackhuang.hmcl.ui.construct.RipplerContainer; @@ -42,28 +41,42 @@ import org.jackhuang.hmcl.util.StringUtils; import java.util.List; -import java.util.Objects; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +/// Displays game instances in a popup selection list. +/// /// @author Glavo public final class GameListPopupMenu extends StackPane { /// Shows an instance selection popup relative to its owner. + /// + /// @param owner the node used to position the popup + /// @param vAlign the popup's vertical alignment relative to `owner` + /// @param hAlign the popup's horizontal alignment relative to `owner` + /// @param initOffsetX the horizontal offset from the aligned position + /// @param initOffsetY the vertical offset from the aligned position + /// @param instances the instances to copy into the popup, in display order public static void show(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign, double initOffsetX, double initOffsetY, - HMCLGameRepository repository, List versions) { - showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, repository, versions); + List instances) { + showAndGetPopup(owner, vAlign, hAlign, initOffsetX, initOffsetY, instances); } /// Shows and returns an instance selection popup relative to its owner. + /// + /// @param owner the node used to position the popup + /// @param vAlign the popup's vertical alignment relative to `owner` + /// @param hAlign the popup's horizontal alignment relative to `owner` + /// @param initOffsetX the horizontal offset from the aligned position + /// @param initOffsetY the vertical offset from the aligned position + /// @param instances the instances to copy into the popup, in display order + /// @return the shown popup public static JFXPopup showAndGetPopup(Node owner, JFXPopup.PopupVPosition vAlign, JFXPopup.PopupHPosition hAlign, double initOffsetX, double initOffsetY, - HMCLGameRepository repository, List versions) { + List instances) { GameListPopupMenu menu = new GameListPopupMenu(); - menu.getItems().setAll(versions.stream() - .map(it -> repository.findInstance(it.id())) - .filter(Objects::nonNull) + menu.getItems().setAll(instances.stream() .map(GameItem::new) .toList()); JFXPopup popup = new JFXPopup(menu); 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 5237ca15dd3..27a7b2ec4e7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -24,6 +24,7 @@ import javafx.animation.RotateTransition; import javafx.animation.Timeline; import javafx.beans.property.*; +import javafx.beans.value.ObservableValue; import javafx.collections.FXCollections; import javafx.collections.ObservableList; import javafx.event.EventHandler; @@ -46,12 +47,11 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.game.DefaultGameRepositorySnapshot; import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.DownloadProviders; -import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -76,9 +76,13 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.UnmodifiableView; import java.io.IOException; +import java.time.Instant; +import java.util.Comparator; import java.util.List; import java.util.Objects; import java.util.concurrent.CancellationException; @@ -90,6 +94,7 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; +/// Displays the launcher home controls for the currently selected game repository. public final class MainPage extends StackPane implements DecoratorPage { private static final String ANNOUNCEMENT = "announcement"; @@ -99,8 +104,17 @@ public final class MainPage extends StackPane implements DecoratorPage { private final BooleanProperty showUpdate = new SimpleBooleanProperty(this, "showUpdate"); private final BooleanProperty showUpdateDialog = new SimpleBooleanProperty(this, "showUpdateDialog"); private final ObjectProperty latestVersion = new SimpleObjectProperty<>(this, "latestVersion"); - private final ObservableList versions = FXCollections.observableArrayList(); - private HMCLGameRepository repository; + /// Mutable storage for visible instances from the selected repository's current snapshot. + private final ObservableList mutableInstances = FXCollections.observableArrayList(); + + /// Read-only observable view of [#mutableInstances]. + private final @UnmodifiableView ObservableList instances = + FXCollections.unmodifiableObservableList(mutableInstances); + + /// Current snapshot of the repository selected by [GameDirectoryManager]. + private final ObservableValue selectedRepositorySnapshot = + BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty()) + .flatMap(HMCLGameRepository::snapshotProperty); private TransitionPane announcementPane; private final StackPane updatePane; @@ -212,11 +226,12 @@ public final class MainPage extends StackPane implements DecoratorPage { HBox launchPane = new HBox(); launchPane.getStyleClass().add("launch-pane"); - FXUtils.onScroll(launchPane, versions, list -> { + FXUtils.onChangeAndOperate(selectedRepositorySnapshot, ignored -> updateInstances()); + FXUtils.onScroll(launchPane, instances, list -> { @Nullable HMCLGameInstance currentGame = getCurrentGame(); @Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null; - return Lang.indexWhere(list, instance -> instance.id().equals(currentId)); - }, it -> repository.setSelectedInstance(repository.getInstance(it.id()))); + return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); + }, instance -> instance.getRepository().setSelectedInstance(instance)); StackPane.setAlignment(launchPane, Pos.BOTTOM_RIGHT); { @@ -267,7 +282,7 @@ public void accept(@Nullable HMCLGameInstance currentGame) { JFXPopup.PopupHPosition.RIGHT, 0, -menuButton.getHeight(), - repository, versions + instances ); Node graphic = menuButton.getGraphic(); @@ -409,14 +424,6 @@ public ReadOnlyObjectWrapper stateProperty() { return state; } - public GameDirectory getGameDirectory() { - return repository.getGameDirectory(); - } - - public HMCLGameRepository getRepository() { - return repository; - } - /// Returns the instance shown by the launch controls. /// /// @return the current instance, or `null` when no instance is selected @@ -438,8 +445,14 @@ public void setCurrentGame(@Nullable HMCLGameInstance currentGame) { this.currentGame.set(currentGame); } - public ObservableList getVersions() { - return versions; + /// Returns the observable instances displayed by launch-selection controls. + /// + /// The list is updated from the selected repository's published snapshot and contains no hidden + /// instances. The returned view cannot be mutated. + /// + /// @return the observable launch-menu instances + public @UnmodifiableView ObservableList getInstances() { + return instances; } public boolean isShowUpdate() { @@ -478,9 +491,19 @@ public void setLatestVersion(RemoteVersion latestVersion) { this.latestVersion.set(latestVersion); } - public void initVersions(HMCLGameRepository repository, List versions) { + /// Rebuilds the launch-menu instances from the selected repository's current snapshot. + private void updateInstances() { FXUtils.checkFxUserThread(); - this.repository = repository; - this.versions.setAll(versions); + HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); + List sortedInstances = repository.getSnapshot().getInstances().stream() + .filter(instance -> !instance.getManifest().isHidden()) + .sorted(Comparator + .comparing((HMCLGameInstance instance) -> Lang.requireNonNullElse( + instance.getManifest().releaseTime(), Instant.EPOCH)) + .thenComparing(instance -> VersionNumber.asVersion(repository + .getGameVersion(instance.getManifest()) + .orElse(instance.getId().toString())))) + .toList(); + mutableInstances.setAll(sortedInstances); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java index 74ca4e945d2..fdbd112f640 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java @@ -22,12 +22,10 @@ import javafx.scene.layout.Region; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.setting.Accounts; -import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -56,16 +54,11 @@ import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.*; -import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jetbrains.annotations.Nullable; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Instant; -import java.util.Comparator; -import java.util.List; import java.util.Locale; -import java.util.stream.Collectors; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -117,20 +110,6 @@ public MainPage getMainPage() { FXUtils.onChangeAndOperate(GameDirectoryManager.selectedInstanceProperty(), mainPage::setCurrentGame); mainPage.latestVersionProperty().bind(UpdateChecker.latestVersionProperty()); - - GameDirectoryManager.registerVersionsListener(repository -> { - GameDirectory gameDirectory = repository.getGameDirectory(); - List children = repository.getInstanceManifests().parallelStream() - .filter(version -> !version.isHidden()) - .sorted(Comparator - .comparing((GameInstanceManifest manifest) -> Lang.requireNonNullElse(manifest.releaseTime(), Instant.EPOCH)) - .thenComparing(manifest -> VersionNumber.asVersion(repository.getGameVersion(manifest).orElse(manifest.id().toString())))) - .collect(Collectors.toList()); - runInFX(() -> { - if (gameDirectory == GameDirectoryManager.getSelectedGameDirectory()) - mainPage.initVersions(repository, children); - }); - }); this.mainPage = mainPage; } return mainPage; @@ -157,14 +136,11 @@ protected Skin(RootPage control) { Instances.modifyGameSettings(instance); } }); - FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getVersions(), list -> { + FXUtils.onScroll(gameListItem, getSkinnable().getMainPage().getInstances(), list -> { @Nullable HMCLGameInstance currentGame = getSkinnable().getMainPage().getCurrentGame(); @Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null; - return Lang.indexWhere(list, instance -> instance.id().equals(currentId)); - }, it -> { - HMCLGameRepository repository = getSkinnable().getMainPage().getRepository(); - repository.setSelectedInstance(repository.getInstance(it.id())); - }); + return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); + }, instance -> instance.getRepository().setSelectedInstance(instance)); if (AnimationUtils.isAnimationEnabled()) { FXUtils.prepareOnMouseEnter(gameListItem, Controllers::prepareGameInstancePage); } @@ -251,8 +227,7 @@ public void showGameListPopupMenu(Region gameListItem) { JFXPopup.PopupHPosition.LEFT, gameListItem.getWidth(), 0, - getSkinnable().getMainPage().getRepository(), - getSkinnable().getMainPage().getVersions()); + getSkinnable().getMainPage().getInstances()); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java index be2a2a4accb..a128097c2a2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/terracotta/TerracottaPage.java @@ -91,18 +91,18 @@ public TerracottaPage() { ); MainPage mainPage = Controllers.getRootPage().getMainPage(); - FXUtils.onScroll(item, mainPage.getVersions(), list -> { + FXUtils.onScroll(item, mainPage.getInstances(), list -> { @Nullable HMCLGameInstance currentGame = mainPage.getCurrentGame(); @Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null; - return Lang.indexWhere(list, instance -> instance.id().equals(currentId)); - }, it -> mainPage.getRepository().setSelectedInstance(mainPage.getRepository().getInstance(it.id()))); + return Lang.indexWhere(list, instance -> instance.getId().equals(currentId)); + }, instance -> instance.getRepository().setSelectedInstance(instance)); FXUtils.onSecondaryButtonClicked(item, () -> GameListPopupMenu.show(item, JFXPopup.PopupVPosition.BOTTOM, JFXPopup.PopupHPosition.LEFT, item.getWidth(), 0, - mainPage.getRepository(), mainPage.getVersions())); + mainPage.getInstances())); }) .addNavigationDrawerItem(i18n("terracotta.feedback.title"), SVG.FEEDBACK, () -> FXUtils.openLink(TerracottaMetadata.FEEDBACK_LINK)); BorderPane.setMargin(toolbar, new Insets(0, 0, 12, 0)); From 845706a02b23eb92cefb5cde5f2986ef6851a434 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:52:25 +0800 Subject: [PATCH 061/199] refactor(MainPage, DefaultGameRepository, HMCLGameRepository): update repository snapshot handling and streamline snapshot property methods --- .../hmcl/game/HMCLGameRepository.java | 6 ++++ .../org/jackhuang/hmcl/ui/main/MainPage.java | 28 ++----------------- .../hmcl/game/DefaultGameRepository.java | 2 +- 3 files changed, 10 insertions(+), 26 deletions(-) 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 396dc3a48e5..8d7e81cbaf0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -121,6 +121,12 @@ public HMCLGameRepositorySnapshot getSnapshot() { return (HMCLGameRepositorySnapshot) super.getSnapshot(); } + @Override + @SuppressWarnings("unchecked") + public ReadOnlyObjectProperty snapshotProperty() { + return (ReadOnlyObjectProperty) super.snapshotProperty(); + } + @Override public HMCLGameRepositoryLayout getLayout() { return (HMCLGameRepositoryLayout) super.getLayout(); 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 27a7b2ec4e7..3e833d78ae8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -47,10 +47,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.VersionList; -import org.jackhuang.hmcl.game.DefaultGameRepositorySnapshot; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.task.Schedulers; @@ -76,14 +73,10 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; -import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.UnmodifiableView; import java.io.IOException; -import java.time.Instant; -import java.util.Comparator; -import java.util.List; import java.util.Objects; import java.util.concurrent.CancellationException; import java.util.function.Consumer; @@ -112,7 +105,7 @@ public final class MainPage extends StackPane implements DecoratorPage { FXCollections.unmodifiableObservableList(mutableInstances); /// Current snapshot of the repository selected by [GameDirectoryManager]. - private final ObservableValue selectedRepositorySnapshot = + private final ObservableValue selectedRepositorySnapshot = BindingMapping.of(GameDirectoryManager.selectedRepositoryProperty()) .flatMap(HMCLGameRepository::snapshotProperty); @@ -226,7 +219,7 @@ public final class MainPage extends StackPane implements DecoratorPage { HBox launchPane = new HBox(); launchPane.getStyleClass().add("launch-pane"); - FXUtils.onChangeAndOperate(selectedRepositorySnapshot, ignored -> updateInstances()); + FXUtils.onChangeAndOperate(selectedRepositorySnapshot, ignored -> mutableInstances.setAll(GameDirectoryManager.getSelectedRepository().getDisplayInstances().toList())); FXUtils.onScroll(launchPane, instances, list -> { @Nullable HMCLGameInstance currentGame = getCurrentGame(); @Nullable GameInstanceID currentId = currentGame != null ? currentGame.getId() : null; @@ -491,19 +484,4 @@ public void setLatestVersion(RemoteVersion latestVersion) { this.latestVersion.set(latestVersion); } - /// Rebuilds the launch-menu instances from the selected repository's current snapshot. - private void updateInstances() { - FXUtils.checkFxUserThread(); - HMCLGameRepository repository = GameDirectoryManager.getSelectedRepository(); - List sortedInstances = repository.getSnapshot().getInstances().stream() - .filter(instance -> !instance.getManifest().isHidden()) - .sorted(Comparator - .comparing((HMCLGameInstance instance) -> Lang.requireNonNullElse( - instance.getManifest().releaseTime(), Instant.EPOCH)) - .thenComparing(instance -> VersionNumber.asVersion(repository - .getGameVersion(instance.getManifest()) - .orElse(instance.getId().toString())))) - .toList(); - mutableInstances.setAll(sortedInstances); - } } 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 19d3f46d319..a8b9533dad6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -143,7 +143,7 @@ public DefaultGameRepositorySnapshot getSnapshot() { /// application thread so listeners may safely touch the scene graph. /// /// @return the observable snapshot property - public final ReadOnlyObjectProperty snapshotProperty() { + public ReadOnlyObjectProperty snapshotProperty() { return snapshot; } From 2f3ad347e2cef9e0fe5ecb21b22168205506f4ac Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:53:20 +0800 Subject: [PATCH 062/199] refactor(HMCLGameRepository): enhance instance sorting by adding version comparison to snapshot instances --- .../main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java | 1 + 1 file changed, 1 insertion(+) 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 8d7e81cbaf0..635d2f0d14c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -250,6 +250,7 @@ public Stream getDisplayInstances() { return getSnapshot().getInstances().stream() .filter(it -> !it.getManifest().isHidden()) .sorted(Comparator.comparing((HMCLGameInstance instance) -> Lang.requireNonNullElse(instance.getLaunchManifest().releaseTime(), Instant.EPOCH)) + .thenComparing(DefaultGameInstance::getVersion) .thenComparing(instance -> VersionNumber.asVersion(instance.getId().id()))); } From 5d78396099eb9e51ab1a68bfe8910e6d4857b8cf Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:55:54 +0800 Subject: [PATCH 063/199] refactor(HMCLGameRepository): remove obsolete PROFILE constant to clean up code --- .../main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java | 2 -- 1 file changed, 2 deletions(-) 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 635d2f0d14c..69f97a116a8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -639,8 +639,6 @@ public boolean unmarkInstanceLaunchedAbnormally(GameInstanceID instanceId) { } } - private static final String PROFILE = "{\"selectedProfile\": \"(Default)\",\"profiles\": {\"(Default)\": {\"name\": \"(Default)\"}},\"clientToken\": \"88888888-8888-8888-8888-888888888888\"}"; - // These instance ids are forbidden because they may conflict with modpack configuration filenames private static final Set FORBIDDEN_INSTANCE_IDS = Set.of("modpack", "minecraftinstance", "manifest"); From 42f8bf37ef9b054ee27fae40653dc551d5b6f927 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:56:35 +0800 Subject: [PATCH 064/199] refactor(DefaultGameRepository): remove unused resource-pack manager method to streamline code --- .../org/jackhuang/hmcl/game/DefaultGameRepository.java | 10 ---------- 1 file changed, 10 deletions(-) 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 a8b9533dad6..f6d4ffa3247 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -25,7 +25,6 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.addon.mod.ModManager; -import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; import org.jackhuang.hmcl.download.MaintainTask; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.task.Task; @@ -697,15 +696,6 @@ public ModManager getModManager(GameInstanceID instanceId) throws NoSuchGameInst return getInstance(instanceId).getModManager(); } - /// Returns the resource-pack manager for the registered instance. - /// - /// @param instanceId the instance id - /// @return the instance's shared resource-pack manager - /// @throws NoSuchGameInstanceException if the instance is not registered - public ResourcePackManager getResourcePackManager(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getInstance(instanceId).getResourcePackManager(); - } - @Override public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { return getSnapshot().resolve(manifest); From 84646481b2cb0cf6630d836f248b8433df0d7211 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 05:58:32 +0800 Subject: [PATCH 065/199] refactor(DefaultGameRepository, NativePatcher): remove mod manager method and update patching logic for game instances --- .../java/org/jackhuang/hmcl/game/LauncherHelper.java | 2 +- .../java/org/jackhuang/hmcl/util/NativePatcher.java | 4 ++-- .../org/jackhuang/hmcl/game/DefaultGameRepository.java | 10 ---------- 3 files changed, 3 insertions(+), 13 deletions(-) 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 d36d0221d50..1ffe6c1b342 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -174,7 +174,7 @@ private void launch0() { TaskExecutor executor = checkGameState(repository, setting, version.get()) .thenComposeAsync(java -> { javaVersionRef.set(Objects.requireNonNull(java)); - version.set(NativePatcher.patchNative(repository, version.get(), gameVersion.orElse(null), java, setting, javaArguments)); + version.set(NativePatcher.patchNative(gameInstance, version.get(), gameVersion.orElse(null), java, setting, javaArguments)); if (setting.getInheritable(GameSettings::notCheckGameProperty)) return null; return Task.allOf( diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java index 97450b4cc55..28d6738fc4a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java @@ -73,7 +73,7 @@ public static boolean needPatchMemoryUtil(GameInstanceManifest manifest, int jav ); } - public static GameInstanceManifest patchNative(DefaultGameRepository repository, + public static GameInstanceManifest patchNative(DefaultGameInstance instance, GameInstanceManifest manifest, String gameVersion, JavaRuntime javaVersion, GameSettings.Effective settings, @@ -172,7 +172,7 @@ public static GameInstanceManifest patchNative(DefaultGameRepository repository, } if (lwjglVersionChanged) { - ModManager modManager = repository.getModManager(manifest.id()); + ModManager modManager = instance.getModManager(); try { for (LocalModFile mod : modManager.getLocalFiles()) { if ("sodium".equals(mod.getId())) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index f6d4ffa3247..adcb8838aee 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -24,7 +24,6 @@ import javafx.beans.property.ReadOnlyLongWrapper; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; -import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.download.MaintainTask; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.task.Task; @@ -687,15 +686,6 @@ public boolean isModpack(GameInstanceID instanceId) { return Files.exists(getModpackConfiguration(instanceId)); } - /// Returns the mod manager for the registered instance. - /// - /// @param instanceId the instance id - /// @return the instance's shared mod manager - /// @throws NoSuchGameInstanceException if the instance is not registered - public ModManager getModManager(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getInstance(instanceId).getModManager(); - } - @Override public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { return getSnapshot().resolve(manifest); From e7af3f7bf8d48b546d132f25c1c23253d8289827 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 06:23:59 +0800 Subject: [PATCH 066/199] refactor(GameInstance, GameRepository): streamline instance methods by removing repository dependencies and enhancing direct access to instance properties --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 222 +++++++++++++++++- .../hmcl/game/HMCLGameRepository.java | 204 +++------------- .../jackhuang/hmcl/game/LauncherHelper.java | 13 +- .../hmcl/ui/download/DownloadPage.java | 2 +- .../hmcl/ui/export/ExportWizardProvider.java | 2 +- .../ui/export/ModpackFileSelectionPage.java | 10 +- .../hmcl/ui/export/ModpackInfoPage.java | 5 +- .../hmcl/ui/game/GameSettingsPage.java | 11 +- .../hmcl/ui/instances/DownloadListPage.java | 6 +- .../ui/instances/GameAdvancedListItem.java | 2 +- .../ui/instances/GameInstanceIconDialog.java | 9 +- .../hmcl/ui/instances/GameInstancePage.java | 3 +- .../jackhuang/hmcl/ui/instances/GameItem.java | 8 +- .../hmcl/ui/instances/GameListItem.java | 2 +- .../hmcl/ui/instances/ModListPage.java | 10 +- .../hmcl/ui/instances/ModListPageSkin.java | 1 - .../ui/instances/ResourcePackListPage.java | 10 +- .../hmcl/setting/GameDirectoriesTest.java | 39 +++ .../hmcl/game/DefaultGameInstance.java | 93 ++++++++ .../hmcl/game/DefaultGameRepository.java | 93 -------- .../org/jackhuang/hmcl/game/GameInstance.java | 30 +++ .../jackhuang/hmcl/game/GameRepository.java | 25 -- .../hmcl/launch/DefaultLauncher.java | 5 +- .../mcbbs/McbbsModpackCompletionTask.java | 2 +- .../server/ServerModpackCompletionTask.java | 2 +- .../hmcl/game/DefaultGameInstanceTest.java | 32 +++ 26 files changed, 493 insertions(+), 348 deletions(-) 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 e871904d0bb..bce13287a05 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -20,17 +20,25 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.reflect.TypeToken; +import javafx.scene.image.Image; +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.modpack.ModpackConfiguration; +import org.jackhuang.hmcl.setting.DefaultIsolationType; import org.jackhuang.hmcl.setting.GameSettings; +import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.setting.GameSettingsPresetID; import org.jackhuang.hmcl.setting.LauncherSettings; import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator; import org.jackhuang.hmcl.setting.SettingFileUtils; import org.jackhuang.hmcl.setting.SettingsManager; +import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FileSaver; +import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonSchema; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -39,6 +47,7 @@ import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.util.Locale; import java.util.Objects; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -51,7 +60,7 @@ public class HMCLGameInstance extends DefaultGameInstance { private final boolean provisional; /// Whether install-time code currently treats this instance as a modpack for run-directory - /// resolution, before [HMCLGameRepository#isModpack(GameInstanceID)] becomes true. + /// resolution, before [#isModpack()] becomes true. private boolean treatingAsModpack; /// Whether the instance-local game settings file has already been inspected. @@ -166,13 +175,44 @@ public boolean isTreatingAsModpack() { return treatingAsModpack; } + /// Returns the HMCL modpack configuration file for this instance. + /// + /// @return the `modpack.cfg` path in the instance root + @Override + public Path getModpackConfigurationFile() { + return getInstanceRoot().resolve("modpack.cfg"); + } + + /// Returns whether this instance has an HMCL modpack configuration file. + /// + /// @return whether [#getModpackConfigurationFile()] exists + public boolean isModpack() { + return Files.exists(getModpackConfigurationFile()); + } + + /// Reads this instance's HMCL modpack configuration. + /// + /// @return the parsed configuration, or `null` when the file does not exist + /// @throws IOException if the configuration cannot be read + public @Nullable ModpackConfiguration readModpackConfiguration() throws IOException { + Path file = getModpackConfigurationFile(); + if (Files.notExists(file)) { + return null; + } + try { + return JsonUtils.fromJsonFile(file, ModpackConfiguration.class); + } catch (JsonParseException e) { + throw new IOException("Malformed modpack configuration: " + file, e); + } + } + @Override public Path getRunDirectory() { - if (treatingAsModpack || getRepository().isModpack(id)) { + if (treatingAsModpack || isModpack()) { return getInstanceRoot(); } - GameSettings.Instance localSetting = getSettings(); + @Nullable GameSettings.Instance localSetting = getSettings(); boolean useInstanceRunningDirectory = localSetting != null && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY); @@ -225,6 +265,42 @@ private String selectedRunningDirectory( return setting; } + /// Resolves this instance's effective settings against its selected parent preset. + /// + /// @return the effective settings + public GameSettings.Effective getEffectiveSettings() { + @Nullable GameSettings.Instance setting = getSettings(); + return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting); + } + + /// Applies the selected parent preset's default isolation policy to this registered instance. + /// + /// Provisional instances are unchanged because their final manifest has not been indexed yet. + public void applyDefaultIsolationSetting() { + if (isProvisional()) { + return; + } + + @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 -> LibraryAnalyzer.isModded(getResolvedManifest()); + }; + + 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 @@ -342,17 +418,149 @@ public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean /// /// @return a detached copy suitable for installing into another instance public GameSettings.Instance copySettings() { - GameSettings.Instance setting = getSettings(); + @Nullable GameSettings.Instance setting = getSettings(); if (setting != null) { return JsonUtils.clone(LauncherSettings.SETTINGS_GSON, setting, TypeToken.get(GameSettings.Instance.class)); } GameSettings.Instance copied = new GameSettings.Instance(); copied.parentProperty().setValue( - getRepository().getEffectiveGameSettings(id).getPreset().idProperty().getValue()); + getEffectiveSettings().getPreset().idProperty().getValue()); return copied; } + /// Returns the first custom icon file found in this instance's root directory. + /// + /// @return the icon file, or empty when no supported icon file exists + public java.util.Optional getIconFile() { + for (String extension : FXUtils.IMAGE_EXTENSIONS) { + Path file = getInstanceRoot().resolve("icon." + extension); + if (Files.exists(file)) { + return java.util.Optional.of(file); + } + } + return java.util.Optional.empty(); + } + + /// Replaces this instance's custom icon file. + /// + /// Existing supported icon files are removed before `iconFile` is copied. + /// + /// @param iconFile the source icon file + /// @throws IOException if the icon cannot be copied + /// @throws IllegalArgumentException if the file extension is unsupported + public void setIconFile(Path iconFile) throws IOException { + String extension = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT); + if (!FXUtils.IMAGE_EXTENSIONS.contains(extension)) { + throw new IllegalArgumentException("Unsupported icon file: " + extension); + } + + deleteIconFile(); + FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension)); + } + + /// Deletes all supported custom icon files for this instance. + /// + /// Individual deletion failures are logged and do not stop later files from being attempted. + public void deleteIconFile() { + for (String extension : FXUtils.IMAGE_EXTENSIONS) { + Path file = getInstanceRoot().resolve("icon." + extension); + try { + Files.deleteIfExists(file); + } catch (IOException e) { + LOG.warning("Failed to delete instance icon file: " + file, e); + } + } + } + + /// Returns the icon image selected for this instance. + /// + /// The configured built-in icon takes precedence. When the default icon is selected, this method + /// tries a custom icon file and then derives a built-in icon from the instance manifest. + /// + /// @return the selected or derived icon image + public Image getIconImage() { + if (!getRepository().isLoaded()) { + return GameInstanceIconType.DEFAULT.getIcon(); + } + + @Nullable GameSettings.Instance setting = getSettings(); + GameInstanceIconType iconType = setting != null + ? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT) + : GameInstanceIconType.DEFAULT; + if (iconType != GameInstanceIconType.DEFAULT) { + return iconType.getIcon(); + } + + java.util.Optional iconFile = getIconFile(); + if (iconFile.isPresent()) { + try { + return FXUtils.loadImage(iconFile.get(), 64, 64, true, true); + } catch (Exception e) { + LOG.warning("Failed to load instance icon for " + id, e); + } + } + + GameInstanceManifest.Resolved resolvedManifest = getResolvedManifest(); + if (LibraryAnalyzer.isModded(resolvedManifest)) { + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, null); + if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) + return GameInstanceIconType.FABRIC.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) + return GameInstanceIconType.QUILT.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) + return GameInstanceIconType.LEGACY_FABRIC.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) + return GameInstanceIconType.NEO_FORGE.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) + return GameInstanceIconType.FORGE.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) + return GameInstanceIconType.CLEANROOM.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) + return GameInstanceIconType.CHICKEN.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) + return GameInstanceIconType.OPTIFINE.getIcon(); + } + + @Nullable String gameVersion = getRepository().getGameVersion(getLaunchManifest()).orElse(null); + if (gameVersion != null) { + GameVersionNumber version = GameVersionNumber.asGameVersion(gameVersion); + if (version.isAprilFools()) { + return GameInstanceIconType.APRIL_FOOLS.getIcon(); + } else if (version instanceof GameVersionNumber.LegacySnapshot) { + return GameInstanceIconType.COMMAND.getIcon(); + } else if (version instanceof GameVersionNumber.Old) { + return GameInstanceIconType.CRAFT_TABLE.getIcon(); + } + } + return GameInstanceIconType.GRASS.getIcon(); + } + + /// Creates the marker indicating that the most recent launch ended abnormally. + public void markLaunchedAbnormally() { + try { + Files.createFile(getInstanceRoot().resolve(".abnormal")); + } catch (IOException ignored) { + } + } + + /// Deletes the abnormal-launch marker when present. + /// + /// @return whether a regular marker file was present + public boolean unmarkLaunchedAbnormally() { + Path file = getInstanceRoot().resolve(".abnormal"); + if (!Files.isRegularFile(file)) { + return false; + } + + try { + Files.delete(file); + } catch (IOException e) { + LOG.warning("Failed to delete abnormal launch marker: " + file, e); + } + return true; + } + private void ensureGameSettingsLoaded() { if (!gameSettingsLoaded) { loadGameSettings(); @@ -419,8 +627,8 @@ private static LoadResult loadGameSettingsFile(Path file) { case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: " + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> - LOG.warning("Unsupported instance game settings schema. Expected: " - + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); + LOG.warning("Unsupported instance game settings schema. Expected: " + + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { } } 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 69f97a116a8..69e1888dbff 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -22,11 +22,9 @@ import javafx.beans.binding.ObjectBinding; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; -import javafx.scene.image.Image; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.EventManager; import org.jackhuang.hmcl.java.JavaRuntime; @@ -42,8 +40,6 @@ import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.setting.ProxyType; import org.jackhuang.hmcl.setting.GameSettingsPresetID; -import org.jackhuang.hmcl.setting.GameInstanceIconType; -import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -320,37 +316,34 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null)); } - /// Creates empty instance-local game settings for an indexed instance when none are loaded. + /// Returns instance-local settings for an instance ID, creating empty settings when the instance + /// is registered and its settings file is absent and writable. /// - /// @param instanceId the instance id - /// @return the settings, or `null` when the instance is missing or settings are read-only - public @Nullable GameSettings.Instance createInstanceGameSettings(GameInstanceID instanceId) { - if (!hasInstance(instanceId)) { - return null; + /// This ID-based entry point is retained for installation before an instance has entered the + /// registered snapshot. Code that already has an [HMCLGameInstance] should use + /// [HMCLGameInstance#getSettingsOrCreate()] instead. + /// + /// @param instanceId the indexed or pending instance ID + /// @return the settings, or `null` when no settings exist and none can be created + public @Nullable GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) { + HMCLGameInstance instance = resolveInstance(instanceId); + @Nullable GameSettings.Instance setting = instance.getSettings(); + if (setting == null && hasInstance(instanceId)) { + setting = instance.createSettings(); } - return resolveInstance(instanceId).createSettings(); + return setting; } - /// Returns the loaded instance-local game settings for the given id. + /// Returns instance-local settings for an indexed or provisional instance ID. /// - /// @param instanceId the instance id - /// @return the settings, or `null` when no local settings exist after loading - @Nullable - public GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) { - return resolveInstance(instanceId).getSettings(); - } - - /// Returns the instance-local game settings, creating empty settings when absent. + /// This ID-based entry point is retained for installation and legacy migration before an + /// instance has entered the registered snapshot. Code that already has an [HMCLGameInstance] + /// should use [HMCLGameInstance#getSettings()] instead. /// - /// @param instanceId the instance id - /// @return the settings, or `null` when the instance is not indexed and no settings can be created - @Nullable - public GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) { - GameSettings.Instance setting = getInstanceGameSettings(instanceId); - if (setting == null) { - setting = createInstanceGameSettings(instanceId); - } - return setting; + /// @param instanceId the indexed or pending instance ID + /// @return the settings, or `null` when no local settings exist + public @Nullable GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) { + return resolveInstance(instanceId).getSettings(); } /// Returns the explicit parent preset of the instance, falling back to the default preset. @@ -360,31 +353,16 @@ public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate(); } + /// Resolves effective settings for an indexed or provisional instance ID. + /// + /// This ID-based entry point is retained for launch construction and installation code that has + /// not yet obtained an [HMCLGameInstance]. Instance-oriented callers should use + /// [HMCLGameInstance#getEffectiveSettings()] instead. + /// + /// @param instanceId the indexed or pending instance ID + /// @return the effective settings public GameSettings.Effective getEffectiveGameSettings(GameInstanceID instanceId) { - GameSettings.Instance instance = getInstanceGameSettings(instanceId); - return GameSettings.resolve(getParentGameSettings(instance), instance); - } - - public void applyDefaultIsolationSetting(GameInstanceID instanceId) { - if (!hasInstance(instanceId)) { - return; - } - - GameSettings.Instance instanceSetting = getInstanceGameSettings(instanceId); - GameSettings.Preset preset = getParentGameSettings(instanceSetting); - DefaultIsolationType type = Lang.requireNonNullElse(preset.defaultIsolationTypeProperty().getValue(), DefaultIsolationType.MODDED); - boolean isolated = switch (type) { - case NEVER -> false; - case ALWAYS -> true; - case MODDED -> LibraryAnalyzer.isModded(getResolvedInstanceManifest(instanceId)); - }; - - if (isolated) { - GameSettings.Instance setting = instanceSetting != null ? instanceSetting : getInstanceGameSettingsOrCreate(instanceId); - if (setting != null && setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { - saveGameSettings(instanceId); - } - } + return resolveInstance(instanceId).getEffectiveSettings(); } /// Returns whether a new instance should use an isolated running directory under the default isolation settings. @@ -414,104 +392,6 @@ public void applyDefaultIsolationSettingForNewInstance(GameInstanceID instanceId } } - public Optional getInstanceIconFile(GameInstanceID instanceId) { - Path root = getLayout().getInstanceRoot(instanceId); - - for (String extension : FXUtils.IMAGE_EXTENSIONS) { - Path file = root.resolve("icon." + extension); - if (Files.exists(file)) { - return Optional.of(file); - } - } - - return Optional.empty(); - } - - public void setInstanceIconFile(GameInstanceID instanceId, Path iconFile) throws IOException { - String ext = FileUtils.getExtension(iconFile).toLowerCase(Locale.ROOT); - if (!FXUtils.IMAGE_EXTENSIONS.contains(ext)) { - throw new IllegalArgumentException("Unsupported icon file: " + ext); - } - - deleteIconFile(instanceId); - - FileUtils.copyFile(iconFile, getLayout().getInstanceRoot(instanceId).resolve("icon." + ext)); - } - - public void deleteIconFile(GameInstanceID instanceId) { - Path root = getLayout().getInstanceRoot(instanceId); - for (String extension : FXUtils.IMAGE_EXTENSIONS) { - Path file = root.resolve("icon." + extension); - try { - Files.deleteIfExists(file); - } catch (IOException e) { - LOG.warning("Failed to delete icon file: " + file, e); - } - } - } - - public Image getInstanceIconImage(@Nullable GameInstanceID instanceId) { - if (instanceId == null || !isLoaded()) - return GameInstanceIconType.DEFAULT.getIcon(); - - GameSettings.Instance setting = getInstanceGameSettings(instanceId); - GameInstanceIconType iconType = setting != null ? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT) : GameInstanceIconType.DEFAULT; - - if (iconType == GameInstanceIconType.DEFAULT) { - GameInstanceManifest.Resolved resolvedInstanceManifest = getResolvedInstanceManifest(instanceId); - Optional iconFile = getInstanceIconFile(instanceId); - if (iconFile.isPresent()) { - try { - return FXUtils.loadImage(iconFile.get(), 64, 64, true, true); - } catch (Exception e) { - LOG.warning("Failed to load instance icon of " + instanceId, e); - } - } - - if (LibraryAnalyzer.isModded(resolvedInstanceManifest)) { - LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(resolvedInstanceManifest, null); - if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) - return GameInstanceIconType.FABRIC.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.QUILT)) - return GameInstanceIconType.QUILT.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) - return GameInstanceIconType.LEGACY_FABRIC.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) - return GameInstanceIconType.NEO_FORGE.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.FORGE)) - return GameInstanceIconType.FORGE.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) - return GameInstanceIconType.CLEANROOM.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) - return GameInstanceIconType.CHICKEN.getIcon(); - else if (libraryAnalyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) - return GameInstanceIconType.OPTIFINE.getIcon(); - } - - String gameVersion = getGameVersion(resolvedInstanceManifest.launchManifest()).orElse(null); - if (gameVersion != null) { - GameVersionNumber versionNumber = GameVersionNumber.asGameVersion(gameVersion); - if (versionNumber.isAprilFools()) { - return GameInstanceIconType.APRIL_FOOLS.getIcon(); - } else if (versionNumber instanceof GameVersionNumber.LegacySnapshot) { - return GameInstanceIconType.COMMAND.getIcon(); - } else if (versionNumber instanceof GameVersionNumber.Old) { - return GameInstanceIconType.CRAFT_TABLE.getIcon(); - } - } - return GameInstanceIconType.GRASS.getIcon(); - } else { - return iconType.getIcon(); - } - } - - /// Saves instance-specific game settings asynchronously when writable. - /// - /// @param instanceId the instance ID - public void saveGameSettings(GameInstanceID instanceId) { - resolveInstance(instanceId).saveSettings(); - } - public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { GameSettings.Effective vs = getEffectiveGameSettings(instanceId); boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty); @@ -617,28 +497,6 @@ public void undoMark(GameInstanceID instanceId) { } } - public void markInstanceLaunchedAbnormally(GameInstanceID instanceId) { - try { - Files.createFile(getLayout().getInstanceRoot(instanceId).resolve(".abnormal")); - } catch (IOException ignored) { - } - } - - public boolean unmarkInstanceLaunchedAbnormally(GameInstanceID instanceId) { - Path file = getLayout().getInstanceRoot(instanceId).resolve(".abnormal"); - if (Files.isRegularFile(file)) { - try { - Files.delete(file); - } catch (IOException e) { - LOG.warning("Failed to delete abnormal mark file: " + file, e); - } - - return true; - } else { - return false; - } - } - // These instance ids are forbidden because they may conflict with modpack configuration filenames private static final Set FORBIDDEN_INSTANCE_IDS = Set.of("modpack", "minecraftinstance", "manifest"); 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 1ffe6c1b342..ff2e6305ff8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -96,7 +96,7 @@ public final class LauncherHelper { public LauncherHelper(HMCLGameInstance gameInstance, Account account) { this.gameInstance = Objects.requireNonNull(gameInstance); this.account = Objects.requireNonNull(account); - this.setting = gameInstance.getRepository().getEffectiveGameSettings(gameInstance.getId()); + this.setting = gameInstance.getEffectiveSettings(); this.launcherVisibility = setting.getInheritable(GameSettings::launcherVisibilityProperty); this.showLogs = setting.getInheritable(GameSettings::showLogsProperty); this.launchingStepsPane.setTitle(i18n("instance.launch")); @@ -164,7 +164,7 @@ private void launch0() { DefaultDependencyManager dependencyManager = repository.getDependency(); AtomicReference version = new AtomicReference<>(MaintainTask.maintain(repository, gameInstance.getResolvedManifest().launchManifest())); Optional gameVersion = repository.getGameVersion(version.get()); - boolean integrityCheck = repository.unmarkInstanceLaunchedAbnormally(selectedInstanceId); + boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally(); CountDownLatch launchingLatch = new CountDownLatch(1); List javaAgents = new ArrayList<>(0); List javaArguments = new ArrayList<>(0); @@ -181,8 +181,11 @@ private void launch0() { dependencyManager.checkGameCompletionAsync(gameInstance, version.get(), integrityCheck), Task.composeAsync(() -> { try { - ModpackConfiguration configuration = ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(selectedInstanceId)); - ModpackProvider provider = ModpackHelper.getProviderByType(configuration.getType()); + @Nullable ModpackConfiguration configuration = + gameInstance.readModpackConfiguration(); + if (configuration == null) return null; + @Nullable ModpackProvider provider = + ModpackHelper.getProviderByType(configuration.getType()); if (provider == null) return null; else return provider.createCompletionTask( dependencyManager, @@ -1053,7 +1056,7 @@ public void onExit(int exitCode, ExitType exitType) { } if (exitType != ExitType.NORMAL) { - repository.markInstanceLaunchedAbnormally(manifest.id()); + gameInstance.markLaunchedAbnormally(); runLater(() -> new GameCrashWindow(process, exitType, repository, manifest, launchOptions, logs).show()); } 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 46d5a335017..43f7e60b675 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 @@ -323,7 +323,7 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { repository.applyDefaultIsolationSettingForNewInstance(instanceId, settings.isInstallingModdedVersion()); return builder.buildAsync().whenComplete(any -> { repository.refresh(); - repository.applyDefaultIsolationSetting(instanceId); + repository.getInstance(instanceId).applyDefaultIsolationSetting(); }).thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId))); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java index 92ad71e2cd8..6f82e466736 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java @@ -184,7 +184,7 @@ private Task exportAsMultiMC(ModpackExportInfo exportInfo, Path modpackFile) @Override public void execute() { HMCLGameInstance instance = resolveCurrentGameInstance(); - GameSettings.Effective setting = instance.getRepository().getEffectiveGameSettings(instance.getId()); + GameSettings.Effective setting = instance.getEffectiveSettings(); dependency = new MultiMCModpackExportTask(instance, exportInfo.getWhitelist(), new MultiMCInstanceConfiguration( "OneSix", diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java index 61007ae2642..c7e12fa9a6d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackFileSelectionPage.java @@ -30,7 +30,6 @@ import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.ui.FXUtils; @@ -71,7 +70,6 @@ public ModpackFileSelectionPage(WizardController controller, HMCLGameInstance ga this.controller = controller; this.gameInstance = gameInstance; this.adviser = adviser; - HMCLGameRepository repository = gameInstance.getRepository(); GameInstanceID instanceId = gameInstance.getId(); JFXTreeView treeView = new JFXTreeView<>(); @@ -100,17 +98,17 @@ public ModpackFileSelectionPage(WizardController controller, HMCLGameInstance ga btnNext.setOnAction(e -> onNext()); nextPane.getChildren().setAll(btnNext); - loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext); - spinnerPane.setOnFailedAction((__) -> loadRoot(repository, treeView, placeholderPane, spinnerPane, btnNext)); + loadRoot(treeView, placeholderPane, spinnerPane, btnNext); + spinnerPane.setOnFailedAction((__) -> loadRoot(treeView, placeholderPane, spinnerPane, btnNext)); this.setBottom(nextPane); } - private void loadRoot(HMCLGameRepository repository, JFXTreeView treeView, StackPane placeholderPane, SpinnerPane spinnerPane, JFXButton btnNext) { + private void loadRoot(JFXTreeView treeView, StackPane placeholderPane, SpinnerPane spinnerPane, JFXButton btnNext) { spinnerPane.setLoading(true); btnNext.setDisable(true); CompletableFuture - .supplyAsync(() -> getTreeItem(repository.getRunDirectory(gameInstance.getId()), "minecraft", 0), Schedulers.io()) + .supplyAsync(() -> getTreeItem(gameInstance.getRunDirectory(), "minecraft", 0), Schedulers.io()) .whenCompleteAsync((root, throwable) -> { if (throwable == null) { if (root != null) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java index f815d6f0bc8..98cc08c3033 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ModpackInfoPage.java @@ -35,10 +35,7 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.auth.Account; import org.jackhuang.hmcl.auth.authlibinjector.AuthlibInjectorServer; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import java.util.Objects; import org.jackhuang.hmcl.modpack.ModpackExportInfo; import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackManifest; import org.jackhuang.hmcl.setting.Accounts; @@ -100,7 +97,7 @@ public ModpackInfoPage(WizardController controller, HMCLGameInstance gameInstanc name.set(gameInstance.getId().toString()); author.set(Optional.ofNullable(Accounts.getSelectedAccount()).map(Account::getProfileName).orElse("")); - GameSettings.Effective versionSetting = gameInstance.getRepository().getEffectiveGameSettings(gameInstance.getId()); + GameSettings.Effective versionSetting = gameInstance.getEffectiveSettings(); minMemory.set(Optional.ofNullable(versionSetting.getInheritable(GameSettings::minMemoryProperty)).orElse(0)); launchArguments.set(versionSetting.getInheritable(GameSettings::gameArgumentsProperty)); javaArguments.set(versionSetting.getInheritable(GameSettings::jvmOptionsProperty)); 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 c985db1cb2e..99638853a00 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/game/GameSettingsPage.java @@ -1872,7 +1872,7 @@ private void bindRunningDirectoryProperty( private boolean isCurrentInstanceModpack() { HMCLGameInstance gameInstance = this.gameInstance.get(); - return gameInstance != null && gameInstance.getRepository().isModpack(gameInstance.getId()); + return gameInstance != null && gameInstance.isModpack(); } /// Returns the current instance version root displayed for modpack running directories. @@ -2732,7 +2732,7 @@ private void loadIcon() { return; } - iconPickerItem.setImage(gameInstance.getRepository().getInstanceIconImage(gameInstance.getId())); + iconPickerItem.setImage(gameInstance.getIconImage()); } /// Refreshes Java selection controls and keeps inherited parent Java properties observed. @@ -2803,9 +2803,8 @@ private void initJavaSubtitle() { JavaVersionType javaVersionType = setting.javaTypeProperty().getValue(); HMCLGameInstance gameInstance = this.gameInstance.get(); - GameSettings.Effective effectiveSetting = gameInstance != null - ? gameInstance.getRepository().getEffectiveGameSettings(gameInstance.getId()) - : null; + @Nullable GameSettings.Effective effectiveSetting = + gameInstance != null ? gameInstance.getEffectiveSettings() : null; JavaVersionType effectiveJavaVersionType = effectiveSetting != null ? effectiveSetting.getInheritable(GameSettings::javaTypeProperty) : javaVersionType; boolean autoSelected = effectiveJavaVersionType == JavaVersionType.AUTO || effectiveJavaVersionType == JavaVersionType.VERSION; @@ -2857,7 +2856,7 @@ private void onDeleteIcon() { return; } - gameInstance.getRepository().deleteIconFile(gameInstance.getId()); + gameInstance.deleteIconFile(); GameSettings.Instance localGameSettings = gameInstance.getSettingsOrCreate(); if (localGameSettings != null) { localGameSettings.iconProperty().setValue(GameInstanceIconType.DEFAULT); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java index da6a9582a18..45daba98ed1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadListPage.java @@ -166,10 +166,12 @@ private void search(String userGameVersion, RemoteAddonRepository.Category categ int currentSearchID = searchID = searchID + 1; Task.supplyAsync(() -> { HMCLGameInstance.Optional instanceReference = this.instanceReference.get(); - if (instanceReference.instanceId() == null) { + @Nullable HMCLGameInstance instance = instanceReference.instance(); + if (instance == null) { return userGameVersion; } else { - return instanceReference.repository().getGameVersion(instanceReference.instanceId()).orElse(""); + GameVersionNumber version = instance.getVersion(); + return version != GameVersionNumber.unknown() ? version.toString() : ""; } }).thenApplyAsync( gameVersion -> repository.search(downloadProvider, gameVersion, category, pageOffset, 50, searchFilter, sort, RemoteAddonRepository.SortOrder.DESC) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java index c6265aff550..2bdea2ee71e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java @@ -58,7 +58,7 @@ private void loadInstance(@Nullable HMCLGameInstance instance) { if (instance != null) { setTitle(i18n("instance.manage.manage")); setSubtitle(instance.getId().toString()); - imageContainer.setImage(instance.getRepository().getInstanceIconImage(instance.getId())); + imageContainer.setImage(instance.getIconImage()); return; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java index 5e787794134..aafc10de715 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java @@ -22,9 +22,7 @@ import javafx.scene.layout.FlowPane; import javafx.stage.FileChooser; import org.jackhuang.hmcl.event.Event; -import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.ui.Controllers; @@ -32,6 +30,7 @@ import org.jackhuang.hmcl.ui.SVG; import org.jackhuang.hmcl.ui.construct.DialogPane; import org.jackhuang.hmcl.ui.construct.RipplerContainer; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Path; @@ -42,12 +41,12 @@ public class GameInstanceIconDialog extends DialogPane { private final HMCLGameInstance gameInstance; private final Runnable onFinish; - private final GameSettings.Instance setting; + private final GameSettings.@Nullable Instance setting; public GameInstanceIconDialog(HMCLGameInstance gameInstance, Runnable onFinish) { this.gameInstance = gameInstance; this.onFinish = onFinish; - this.setting = gameInstance.getRepository().getInstanceGameSettingsOrCreate(gameInstance.getId()); + this.setting = gameInstance.getSettingsOrCreate(); setTitle(i18n("settings.icon")); FlowPane pane = new FlowPane(); @@ -78,7 +77,7 @@ private void exploreIcon() { Path selectedFile = Controllers.showOpenDialog(chooser); if (selectedFile != null) { try { - gameInstance.getRepository().setInstanceIconFile(gameInstance.getId(), selectedFile); + gameInstance.setIconFile(selectedFile); if (setting != null) { setting.iconProperty().setValue(GameInstanceIconType.DEFAULT); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index efbed3d04c9..47e422ee0f0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -112,8 +112,7 @@ public GameInstancePage() { return; } HMCLGameInstance gameInstance = current.instance(); - currentInstanceUpgradable.set( - gameInstance != null && current.repository().isModpack(gameInstance.getId())); + currentInstanceUpgradable.set(gameInstance != null && gameInstance.isModpack()); if (gameInstance != null) { preferredInstanceId = gameInstance.getId(); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java index 34321ead79a..17eba127449 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java @@ -92,10 +92,10 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { CompletableFuture.supplyAsync(() -> { // GameVersion.minecraftVersion() is a time-costing job (up to ~200 ms) GameVersionNumber version = gameInstance.getVersion(); - String gameVersion = version == GameVersionNumber.unknown() ? null : version.toString(); - String modPackVersion = null; + @Nullable String gameVersion = version == GameVersionNumber.unknown() ? null : version.toString(); + @Nullable String modPackVersion = null; try { - ModpackConfiguration config = gameInstance.getRepository().readModpackConfiguration(gameInstance.getId()); + @Nullable ModpackConfiguration config = gameInstance.readModpackConfiguration(); modPackVersion = config != null ? config.getVersion() : null; } catch (IOException e) { LOG.warning("Failed to read modpack configuration from " + getId(), e); @@ -127,7 +127,7 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { }, Schedulers.javafx()); title.set(getId()); - image.set(gameInstance.getRepository().getInstanceIconImage(gameInstance.getId())); + image.set(gameInstance.getIconImage()); } public ReadOnlyStringProperty titleProperty() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java index b8b2a090b83..402e0124e43 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameListItem.java @@ -35,7 +35,7 @@ public GameListItem(HMCLGameInstance gameInstance) { super(gameInstance); HMCLGameRepository repository = gameInstance.getRepository(); GameInstanceID instanceId = gameInstance.getId(); - this.isModpack = repository.isModpack(instanceId); + this.isModpack = gameInstance.isModpack(); selected.bind(Bindings.createBooleanBinding( () -> { if (repository.getGameDirectory() != GameDirectoryManager.getSelectedGameDirectory()) return false; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index 011ca94796f..9dae21b535c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -41,6 +41,7 @@ import org.jackhuang.hmcl.ui.construct.PageAware; import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -264,8 +265,11 @@ public void checkUpdates(Collection mods) { HMCLGameInstance gameInstance = this.gameInstance; Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { - Optional gameVersion = gameInstance.getRepository().getGameVersion(gameInstance.getId()); - return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, mods)).orElse(null); + GameVersionNumber version = gameInstance.getVersion(); + return version != GameVersionNumber.unknown() + ? new AddonCheckUpdatesTask<>( + DownloadProviders.getDownloadProvider(), version.toString(), mods) + : null; }) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception instanceof CancellationException) return; @@ -280,7 +284,7 @@ public void checkUpdates(Collection mods) { .withStagesHints("update.checking"), i18n("addon.check_update"), TaskCancellationAction.NORMAL); - if (gameInstance.getRepository().isModpack(gameInstance.getId())) { + if (gameInstance.isModpack()) { Controllers.confirm( i18n("mods.update_modpack_mod.warning"), null, MessageDialogPane.MessageType.WARNING, diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java index 3747ec5361e..e3645aab8e9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPageSkin.java @@ -45,7 +45,6 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.task.Schedulers; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java index 980272dd448..54813f4910a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java @@ -61,6 +61,7 @@ import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.TaskCancellationAction; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -260,8 +261,11 @@ public void checkUpdates(Collection resourcePacks) { Runnable action = () -> Controllers.taskDialog(Task .composeAsync(() -> { - Optional gameVersion = gameInstance.getRepository().getGameVersion(gameInstance.getId()); - return gameVersion.map(g -> new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), g, resourcePacks)).orElse(null); + GameVersionNumber version = gameInstance.getVersion(); + return version != GameVersionNumber.unknown() + ? new AddonCheckUpdatesTask<>(DownloadProviders.getDownloadProvider(), + version.toString(), resourcePacks) + : null; }) .whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception != null || result == null) { @@ -275,7 +279,7 @@ public void checkUpdates(Collection resourcePacks) { .withStagesHints("update.checking"), i18n("addon.check_update"), TaskCancellationAction.NORMAL); - if (gameInstance.getRepository().isModpack(gameInstance.getId())) { + if (gameInstance.isModpack()) { Controllers.confirm( i18n("resourcepack.update_in_modpack.warning"), null, MessageDialogPane.MessageType.WARNING, 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 7258cdaae17..d6787691890 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -665,6 +665,45 @@ public void newInstanceAfterMigrationDoesNotUseLegacyGameDirectoryParent(@TempDi } } + /// Tests that HMCL-specific instance files are managed through [HMCLGameInstance]. + @Test + public void instanceOwnsHmclSpecificFiles(@TempDir Path tempDirectory) throws Exception { + GameDirectory gameDirectory = new GameDirectory( + GameDirectoryID.generate(), + LocalizedText.plain("Dev"), + PortablePath.of(tempDirectory.toString())); + GameDirectories localDirectories = new GameDirectories(); + localDirectories.getGameDirectories().add(gameDirectory); + GameDirectories userDirectories = new GameDirectories(); + + try (GameDirectoryEnvironment ignored = + new GameDirectoryEnvironment(localDirectories, userDirectories)) { + HMCLGameRepository repository = new HMCLGameRepository(gameDirectory); + GameInstanceID instanceId = new GameInstanceID("1.20.1"); + repository.saveAsync(new GameInstanceManifest(instanceId)).run(); + HMCLGameInstance instance = repository.getInstance(instanceId); + + Path configurationFile = instance.getInstanceRoot().resolve("modpack.cfg"); + assertEquals(configurationFile, instance.getModpackConfigurationFile()); + assertFalse(instance.isModpack()); + Files.writeString(configurationFile, "{}"); + assertTrue(instance.isModpack()); + + Path abnormalMarker = instance.getInstanceRoot().resolve(".abnormal"); + instance.markLaunchedAbnormally(); + assertTrue(Files.isRegularFile(abnormalMarker)); + assertTrue(instance.unmarkLaunchedAbnormally()); + assertFalse(Files.exists(abnormalMarker)); + + Path sourceIcon = tempDirectory.resolve("source.png"); + Files.write(sourceIcon, new byte[]{1, 2, 3}); + instance.setIconFile(sourceIcon); + assertEquals(instance.getInstanceRoot().resolve("icon.png"), instance.getIconFile().orElseThrow()); + instance.deleteIconFile(); + assertTrue(instance.getIconFile().isEmpty()); + } + } + /// Tests that repository selection exposes the current snapshot member while persisting its ID. @Test public void selectedInstanceTracksRepositorySnapshots(@TempDir Path tempDirectory) 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 df8839e8e33..b9f0d974a0c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -17,15 +17,19 @@ */ package org.jackhuang.hmcl.game; +import com.google.gson.JsonParseException; import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -241,6 +245,12 @@ public Path getManifestFile() { return manifestFile != null ? manifestFile : layout.getInstanceJson(id); } + /// {@inheritDoc} + @Override + public Path getModpackConfigurationFile() { + return getInstanceRoot().resolve("modpack.json"); + } + /// {@inheritDoc} /// /// When the launch manifest redirects to another version via [GameInstanceManifest#jar()], the @@ -277,4 +287,87 @@ Path getOwnJarFile() { public Path getRunDirectory() { return getRepository().getRunDirectory(id); } + + /// {@inheritDoc} + @Override + public AssetIndex getAssetIndex(String assetId) throws IOException { + try { + return Objects.requireNonNull( + JsonUtils.fromJsonFile(getLayout().getAssetIndexFile(assetId), AssetIndex.class)); + } catch (JsonParseException | NullPointerException e) { + throw new IOException("Asset index file malformed", e); + } + } + + /// {@inheritDoc} + @Override + public Path getActualAssetDirectory(String assetId) { + try { + return reconstructAssets(assetId); + } catch (IOException | JsonParseException e) { + LOG.error("Unable to reconstruct asset directory", e); + return getLayout().getAssetDirectory(); + } + } + + /// {@inheritDoc} + @Override + public Optional getAssetObject(String assetId, String name) throws IOException { + try { + @Nullable AssetObject assetObject = getAssetIndex(assetId).getObjects().get(name); + return assetObject != null + ? Optional.of(getLayout().getAssetObject(assetObject)) + : Optional.empty(); + } catch (IOException e) { + throw e; + } catch (Exception e) { + throw new IOException( + "Unrecognized asset object " + name + " in asset " + assetId + " of version " + id, + e); + } + } + + /// Reconstructs virtual and legacy resource layouts for an asset index when required. + /// + /// @param assetId the asset index ID + /// @return the directory to supply at launch time + /// @throws IOException if an asset cannot be copied + /// @throws JsonParseException if the asset index is malformed + private Path reconstructAssets(String assetId) throws IOException, JsonParseException { + Path assetsDir = getLayout().getAssetDirectory(); + Path indexFile = getLayout().getAssetIndexFile(assetId); + Path virtualRoot = assetsDir.resolve("virtual").resolve(assetId); + + if (!Files.isRegularFile(indexFile)) { + return assetsDir; + } + + @Nullable AssetIndex index = JsonUtils.fromJsonFile(indexFile, AssetIndex.class); + if (index == null || !index.isVirtual()) { + return assetsDir; + } + + Path resourcesDir = getRunDirectory().resolve("resources"); + int existingObjects = 0; + int totalObjects = index.getObjects().size(); + for (Map.Entry entry : index.getObjects().entrySet()) { + Path target = virtualRoot.resolve(entry.getKey()); + Path original = getLayout().getAssetObject(entry.getValue()); + if (Files.exists(original)) { + existingObjects++; + if (!Files.isRegularFile(target)) { + FileUtils.copyFile(original, target); + } + + if (index.needMapToResources()) { + target = resourcesDir.resolve(entry.getKey()); + if (!Files.isRegularFile(target)) { + FileUtils.copyFile(original, target); + } + } + } + } + + return existingObjects * 10 < totalObjects ? assetsDir : virtualRoot; + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index adcb8838aee..3d2819321c1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -25,7 +25,6 @@ import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.download.MaintainTask; -import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -568,86 +567,6 @@ public Path getInstanceJson(GameInstanceID instanceId) { return getLayout().getInstanceJson(instanceId); } - @Override - public AssetIndex getAssetIndex(GameInstanceID instanceId, String assetId) throws IOException { - try { - return Objects.requireNonNull(JsonUtils.fromJsonFile(getLayout().getAssetIndexFile(assetId), AssetIndex.class)); - } catch (JsonParseException | NullPointerException e) { - throw new IOException("Asset index file malformed", e); - } - } - - @Override - public Path getActualAssetDirectory(GameInstanceID instanceId, String assetId) { - try { - return reconstructAssets(instanceId, assetId); - } catch (IOException | JsonParseException e) { - LOG.error("Unable to reconstruct asset directory", e); - return getLayout().getAssetDirectory(); - } - } - - @Override - public Optional getAssetObject(GameInstanceID instanceId, String assetId, String name) throws IOException { - try { - AssetObject assetObject = getAssetIndex(instanceId, assetId).getObjects().get(name); - if (assetObject == null) return Optional.empty(); - return Optional.of(getLayout().getAssetObject(assetObject)); - } catch (IOException e) { - throw e; - } catch (Exception e) { - throw new IOException("Unrecognized asset object " + name + " in asset " + assetId + " of version " + instanceId, e); - } - } - - public Path getAssetObject(GameInstanceID instanceId, Path assetDir, AssetObject obj) { - return assetDir.resolve("objects").resolve(obj.getLocation()); - } - - protected Path reconstructAssets(GameInstanceID instanceId, String assetId) throws IOException, JsonParseException { - Path assetsDir = getLayout().getAssetDirectory(); - Path indexFile = getLayout().getAssetIndexFile(assetId); - Path virtualRoot = assetsDir.resolve("virtual").resolve(assetId); - - if (!Files.isRegularFile(indexFile)) - return assetsDir; - - AssetIndex index = JsonUtils.fromJsonFile(indexFile, AssetIndex.class); - - if (index == null) - return assetsDir; - - if (index.isVirtual()) { - Path resourcesDir = getRunDirectory(instanceId).resolve("resources"); - - int cnt = 0; - int tot = index.getObjects().size(); - for (Map.Entry entry : index.getObjects().entrySet()) { - Path target = virtualRoot.resolve(entry.getKey()); - Path original = getAssetObject(instanceId, assetsDir, entry.getValue()); - if (Files.exists(original)) { - cnt++; - if (!Files.isRegularFile(target)) - FileUtils.copyFile(original, target); - - if (index.needMapToResources()) { - target = resourcesDir.resolve(entry.getKey()); - if (!Files.isRegularFile(target)) - FileUtils.copyFile(original, target); - } - } - } - - // If the scale new format existent file is lower than 0.1, use the old format. - if (cnt * 10 < tot) - return assetsDir; - else - return virtualRoot; - } - - return assetsDir; - } - public Task saveAsync(GameInstanceManifest instanceManifest) { return Task.supplyAsync(() -> { GameInstanceManifest savedManifest = instanceManifest.isResolvedPreservingPatches() @@ -674,18 +593,6 @@ public Path getModpackConfiguration(GameInstanceID instanceId) { return getInstanceRoot(instanceId).resolve("modpack.json"); } - @Nullable - public ModpackConfiguration readModpackConfiguration(GameInstanceID instanceId) throws IOException, NoSuchGameInstanceException { - if (!hasInstance(instanceId)) throw new NoSuchGameInstanceException(instanceId); - Path file = getModpackConfiguration(instanceId); - if (Files.notExists(file)) return null; - return JsonUtils.fromJsonFile(file, ModpackConfiguration.class); - } - - public boolean isModpack(GameInstanceID instanceId) { - return Files.exists(getModpackConfiguration(instanceId)); - } - @Override public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { return getSnapshot().resolve(manifest); 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 09656fe1cd7..18a8bc60cfd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -21,7 +21,9 @@ import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; +import java.io.IOException; import java.nio.file.Path; +import java.util.Optional; /// Provides a view of a game instance and its instance-specific paths within a /// [GameRepositorySnapshot]. @@ -71,6 +73,11 @@ default GameInstanceManifest getLaunchManifest() { /// @return the manifest JSON path Path getManifestFile(); + /// Returns the launcher-specific modpack configuration file for this instance. + /// + /// @return the modpack configuration path in the instance root + Path getModpackConfigurationFile(); + /// Returns the primary client jar selected by the resolved launch manifest. /// /// @return the primary client jar path @@ -81,6 +88,29 @@ default GameInstanceManifest getLaunchManifest() { /// @return the run directory Path getRunDirectory(); + /// Reads an asset index used by this instance. + /// + /// @param assetId the asset index ID + /// @return the parsed asset index + /// @throws IOException if the asset index cannot be read + AssetIndex getAssetIndex(String assetId) throws IOException; + + /// Returns the asset directory that should be supplied when launching this instance. + /// + /// Implementations may reconstruct virtual or legacy resource layouts before returning. + /// + /// @param assetId the asset index ID + /// @return the launch-time asset directory + Path getActualAssetDirectory(String assetId); + + /// Returns an existing asset object by its logical name. + /// + /// @param assetId the asset index ID + /// @param name the logical asset name + /// @return the asset object path, or empty when the index has no such object + /// @throws IOException if the asset index cannot be read + Optional getAssetObject(String assetId, String name) throws IOException; + /// Returns the directory containing mods used by this instance. /// /// @return the mods directory below the run directory 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 b829323e02f..3fc24b56b14 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -22,7 +22,6 @@ import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.NotNullByDefault; -import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Collection; @@ -196,30 +195,6 @@ default Optional getGameVersion(GameInstanceID instanceId) throws NoSuch /// @return whether the instance was renamed boolean renameInstance(GameInstanceID from, GameInstanceID to); - /// Returns the asset directory that should be used at launch time. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @return the actual asset directory - Path getActualAssetDirectory(GameInstanceID instanceId, String assetId); - - /// Returns an existing asset object path by logical asset name. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @param name the logical asset name - /// @return the asset object path, or empty if the object is not present in the asset index - /// @throws IOException if the asset index cannot be read - Optional getAssetObject(GameInstanceID instanceId, String assetId, String name) throws IOException; - - /// Reads an asset index. - /// - /// @param instanceId the instance id - /// @param assetId the asset index id - /// @return the asset index - /// @throws IOException if the asset index cannot be read - AssetIndex getAssetIndex(GameInstanceID instanceId, String assetId) throws IOException; - /// Returns the classpath entries whose library files are present on disk. /// /// @param manifest the manifest whose libraries should be mapped to classpath entries 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 c451474a115..9cebffdf712 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -156,7 +156,7 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) { res.addDefault("-Xdock:name=", "Minecraft " + manifest.id()); - instance.getRepository().getAssetObject(instance.getId(), manifest.getAssetIndex().getId(), "icons/minecraft.icns") + instance.getAssetObject(manifest.getAssetIndex().getId(), "icons/minecraft.icns") .ifPresent(minecraftIcns -> { res.addDefault("-Xdock:icon=", FileUtils.getAbsolutePath(minecraftIcns)); }); @@ -287,7 +287,7 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { classpath.add(FileUtils.getAbsolutePath(jar.toAbsolutePath())); // Provided Minecraft arguments - Path gameAssets = instance.getRepository().getActualAssetDirectory(instance.getId(), manifest.getAssetIndex().getId()); + Path gameAssets = instance.getActualAssetDirectory(manifest.getAssetIndex().getId()); Map configuration = getConfigurations(); configuration.put("${classpath}", String.join(File.pathSeparator, classpath)); configuration.put("${game_assets}", FileUtils.getAbsolutePath(gameAssets)); @@ -483,7 +483,6 @@ public void decompressNatives(Path destination) throws NotDecompressingNativesEx } } - /// Returns the detected Minecraft version string for this instance, if known. /// /// @return the version string, or empty when detection failed diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java index 6bc27fff955..6a415037415 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackCompletionTask.java @@ -92,7 +92,7 @@ public McbbsModpackCompletionTask( this.dependency = dependencyManager; this.instance = instance; this.modManager = instance.getModManager(); - this.configurationFile = instance.getRepository().getModpackConfiguration(instance.getId()); + this.configurationFile = instance.getModpackConfigurationFile(); this.configuration = configuration; setStage("hmcl.modpack.download"); 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 cf2b392f09f..b056d69e8ea 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java @@ -86,7 +86,7 @@ public ServerModpackCompletionTask( dependencyManager.validateGameInstance(instance); this.dependencyManager = dependencyManager; this.instance = instance; - this.configurationFile = instance.getRepository().getModpackConfiguration(instance.getId()); + this.configurationFile = instance.getModpackConfigurationFile(); if (manifest == null) { try { 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 9c7b4a761d6..39eff7b6549 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -55,6 +55,38 @@ @NotNullByDefault public final class DefaultGameInstanceTest { + /// Asset and modpack paths are resolved directly from the owning instance. + @Test + public void testInstanceOwnsAssetAndModpackPaths(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + TestGameInstance instance = repository.publish(instanceId, new GameInstanceManifest(instanceId)); + String assetId = "legacy"; + String assetName = "icons/minecraft.icns"; + String assetHash = "abcdef0123456789"; + Path indexFile = repository.getLayout().getAssetIndexFile(assetId); + Files.createDirectories(indexFile.getParent()); + Files.writeString(indexFile, """ + { + "objects": { + "%s": { + "hash": "%s", + "size": 1 + } + } + } + """.formatted(assetName, assetHash)); + + AssetIndex index = instance.getAssetIndex(assetId); + assertEquals(assetHash, index.getObjects().get(assetName).hash()); + assertEquals( + Optional.of(repository.getLayout().getAssetObject(index.getObjects().get(assetName))), + instance.getAssetObject(assetId, assetName)); + assertEquals(Optional.empty(), instance.getAssetObject(assetId, "missing")); + assertEquals(repository.getLayout().getAssetDirectory(), instance.getActualAssetDirectory(assetId)); + assertEquals(instance.getInstanceRoot().resolve("modpack.json"), instance.getModpackConfigurationFile()); + } + /// The selected primary jar follows the resolved manifest's `jar` field. @Test public void testPrimaryJarUsesResolvedJarField(@TempDir Path tempDirectory) { From 4bbc41af143ed27c123257dcb1e0144d4537fbd6 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 06:43:58 +0800 Subject: [PATCH 067/199] refactor(GameLibrariesTask, LaunchManifestPreparation): enhance library handling and manifest preparation for improved launch compatibility --- .../jackhuang/hmcl/game/LauncherHelper.java | 7 +- .../download/DefaultDependencyManager.java | 4 +- .../download/LaunchManifestPreparation.java | 188 ++++++++++ .../jackhuang/hmcl/download/MaintainTask.java | 341 ------------------ .../hmcl/download/game/GameLibrariesTask.java | 21 +- .../hmcl/game/DefaultGameRepository.java | 24 +- .../game/DefaultGameRepositorySnapshot.java | 18 +- .../hmcl/game/GameInstanceManifest.java | 12 +- .../jackhuang/hmcl/game/GameRepository.java | 2 +- .../hmcl/game/LaunchManifestNormalizer.java | 302 ++++++++++++++++ .../multimc/MultiMCModpackInstallTask.java | 14 +- .../hmcl/game/DefaultGameInstanceTest.java | 106 ++++++ 12 files changed, 657 insertions(+), 382 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java 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 ff2e6305ff8..d7af8f09900 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -26,7 +26,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.download.MaintainTask; +import org.jackhuang.hmcl.download.LaunchManifestPreparation; import org.jackhuang.hmcl.download.game.*; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; @@ -155,6 +155,7 @@ public void makeLaunchScript(Path scriptFile) { launch(); } + /// Builds and executes the launch pipeline for the captured game instance. private void launch0() { // https://github.com/HMCL-dev/HMCL/pull/4121 PROCESSES.removeIf(it -> it.get() == null); @@ -162,7 +163,9 @@ private void launch0() { HMCLGameRepository repository = repository(); GameInstanceID selectedInstanceId = instanceId(); DefaultDependencyManager dependencyManager = repository.getDependency(); - AtomicReference version = new AtomicReference<>(MaintainTask.maintain(repository, gameInstance.getResolvedManifest().launchManifest())); + AtomicReference version = new AtomicReference<>( + LaunchManifestPreparation.prepare( + repository, gameInstance.getResolvedManifest().launchManifest())); Optional gameVersion = repository.getGameVersion(version.get()); boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally(); CountDownLatch launchingLatch = new CountDownLatch(1); 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 4c3f2cc2391..f1e4a5d4c78 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -246,9 +246,7 @@ public UnsupportedLibraryInstallerException() { /// @param libraryId the patch identifier, such as `forge`, `optifine`, or `fabric` /// @return the task producing the updated independent manifest public Task removeLibraryAsync(GameInstanceManifest manifest, String libraryId) { - // MaintainTask requires version that does not inherits from any version. - // If we want to remove a library in dependent version, we should keep the dependents not changed - // So resolving this game version to preserve all information in this version.json is necessary. + // Library removal operates on a standalone manifest so inherited launch metadata is retained. return Task.supplyAsync(() -> { GameInstanceManifest independentVersion = repository.resolve(manifest).standaloneManifest(); String gameVersion = repository.getGameVersion(independentVersion).orElse(null); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java new file mode 100644 index 00000000000..008f385e651 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java @@ -0,0 +1,188 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.download; + +import org.jackhuang.hmcl.game.Argument; +import org.jackhuang.hmcl.game.Artifact; +import org.jackhuang.hmcl.game.GameInstanceLibraryBuilder; +import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.GameRepository; +import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.StringArgument; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.io.File; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; + +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.BOOTSTRAP_LAUNCHER; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.FORGE; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.LITELOADER; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.NEO_FORGE; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.OPTIFINE; + +/// Applies launch-manifest compatibility adjustments that depend on the installed filesystem. +@NotNullByDefault +public final class LaunchManifestPreparation { + /// Prevents construction of this utility class. + private LaunchManifestPreparation() { + } + + /// Prepares a normalized launch manifest using the current library files. + /// + /// The input must not contain inheritance or pending patches. The returned manifest may select + /// a locally installed OptiFine artifact or replace an old BootstrapLauncher ignore list. + /// + /// @param repository the repository that owns the installed libraries + /// @param manifest the normalized launch manifest + /// @return the manifest to use for this launch attempt + /// @throws IllegalArgumentException if the manifest is not structurally resolved + public static GameInstanceManifest prepare( + GameRepository repository, + GameInstanceManifest manifest) { + if (manifest.inheritsFrom() != null || !manifest.getPatches().isEmpty()) { + throw new IllegalArgumentException("Launch manifest must be structurally resolved"); + } + + GameInstanceManifest prepared = prepareBootstrapLauncher(repository, manifest); + if (!LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(prepared.mainClass())) { + prepared = prepareOptiFineLibrary(repository, prepared); + } + return prepared; + } + + /// Replaces unsafe substring-based ignore-list entries used by old BootstrapLauncher versions. + /// + /// @param repository the repository that resolves installed classpath entries + /// @param manifest the normalized launch manifest + /// @return the adjusted manifest + private static GameInstanceManifest prepareBootstrapLauncher( + GameRepository repository, + GameInstanceManifest manifest) { + if (!LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(manifest.mainClass())) { + return manifest; + } + + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); + if (!analyzer.has(FORGE) && !analyzer.has(NEO_FORGE)) { + return manifest; + } + + if (analyzer.getVersion(BOOTSTRAP_LAUNCHER) + .filter(version -> VersionNumber.compare(version, "0.1.17") < 0) + .isEmpty()) { + return manifest; + } + + GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); + List jvmArguments = builder.getMutableJvmArguments(); + for (int i = 0; i < jvmArguments.size(); i++) { + Argument argument = jvmArguments.get(i); + if (argument instanceof StringArgument) { + String value = argument.toString(); + if (value.startsWith("-DignoreList=")) { + jvmArguments.set(i, new StringArgument( + "-DignoreList=" + updateIgnoreList( + repository, + manifest, + value.substring("-DignoreList=".length())))); + } + } + } + return builder.build(); + } + + /// Converts an old BootstrapLauncher ignore list to exact installed classpath entries. + /// + /// @param repository the repository that resolves installed classpath entries + /// @param manifest the launch manifest + /// @param ignoreList the original comma-separated substring list + /// @return the exact comma-separated ignore list + private static String updateIgnoreList( + GameRepository repository, + GameInstanceManifest manifest, + String ignoreList) { + String[] ignoredSubstrings = ignoreList.split(","); + List exactEntries = new ArrayList<>(); + exactEntries.add("${primary_jar}"); + + Path libraryDirectory = repository.getLayout().getLibrariesDirectory().toAbsolutePath().normalize(); + for (String classpathName : repository.getClasspath(manifest)) { + Path classpathFile = Paths.get(classpathName).toAbsolutePath(); + String fileName = classpathFile.getFileName().toString(); + if (Stream.of(ignoredSubstrings).anyMatch(fileName::contains)) { + String absolutePath; + if (classpathFile.startsWith(libraryDirectory)) { + absolutePath = "${library_directory}${file_separator}" + + libraryDirectory.relativize(classpathFile).toString() + .replace(File.separator, "${file_separator}"); + } else { + absolutePath = classpathFile.toString(); + } + exactEntries.add(StringUtils.substringBefore(absolutePath, ",")); + } + } + return String.join(",", exactEntries); + } + + /// Selects the locally installed OptiFine installer artifact required by other loaders. + /// + /// @param repository the repository that owns the installed libraries + /// @param manifest the normalized launch manifest + /// @return the adjusted manifest + private static GameInstanceManifest prepareOptiFineLibrary( + GameRepository repository, + GameInstanceManifest manifest) { + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); + if (!analyzer.has(OPTIFINE) || (!analyzer.has(LITELOADER) && !analyzer.has(FORGE))) { + return manifest; + } + + boolean removeFromClasspath = LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()); + List libraries = new ArrayList<>(); + @Nullable Library selectedInstaller = null; + + for (Library library : manifest.getLibraries()) { + if (library.is("optifine", "OptiFine")) { + Library installer = new Library( + new Artifact("optifine", "OptiFine", library.version(), "installer")); + if (Files.exists(repository.getLayout().getLibraryFile(manifest.id(), installer))) { + selectedInstaller = installer; + } else { + libraries.add(library); + } + } else if (library.is("optifine", "launchwrapper-of")) { + // This modified LaunchWrapper conflicts with Forge and LiteLoader. + } else { + libraries.add(library); + } + } + + if (!removeFromClasspath && selectedInstaller != null) { + libraries.add(selectedInstaller); + } + return manifest.withLibraries(libraries); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java deleted file mode 100644 index 8c8986e9dca..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MaintainTask.java +++ /dev/null @@ -1,341 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2021 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.download; - -import org.jackhuang.hmcl.game.*; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.SimpleMultimap; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.versioning.VersionNumber; - -import java.io.File; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; -import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; - -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -public class MaintainTask extends Task { - private final GameRepository repository; - private final GameInstanceManifest manifest; - - public MaintainTask(GameRepository repository, GameInstanceManifest manifest) { - this.repository = repository; - this.manifest = manifest; - - if (manifest.inheritsFrom() != null) - throw new IllegalArgumentException("MaintainTask requires independent game version"); - } - - @Override - public void execute() { - setResult(maintain(repository, manifest)); - } - - public static GameInstanceManifest maintain(GameRepository repository, GameInstanceManifest manifest) { - if (manifest.inheritsFrom() != null) - throw new IllegalArgumentException("MaintainTask requires independent game version"); - - String mainClass = manifest.resolve(repository).mainClass(); - - if (mainClass != null && mainClass.equals(LibraryAnalyzer.LAUNCH_WRAPPER_MAIN)) { - manifest = maintainOptiFineLibrary(repository, maintainGameWithLaunchWrapper(repository, unique(manifest), true), false); - } else if (mainClass != null && mainClass.equals(LibraryAnalyzer.MOD_LAUNCHER_MAIN)) { - // Forge 1.13 and OptiFine - manifest = maintainOptiFineLibrary(repository, maintainGameWithCpwModLauncher(repository, unique(manifest)), true); - } else if (mainClass != null && mainClass.equals(LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN)) { - // Forge 1.17 - manifest = maintainGameWithCpwBoostrapLauncher(repository, unique(manifest)); - } else { - // Vanilla Minecraft does not need maintain - // Fabric does not need maintain, nothing compatible with fabric now. - manifest = maintainOptiFineLibrary(repository, unique(manifest), false); - } - - List libraries = manifest.getLibraries(); - if (!libraries.isEmpty()) { - // HMCL once use log4j-patch to prevent virus. But now, we only modify log4j2.xml. - // Therefore, we remove this library. - Library library = libraries.get(0); - if ("org.glavo".equals(library.groupId()) - && ("log4j-patch".equals(library.artifactId()) || "log4j-patch-beta9".equals(library.artifactId())) - && "1.0".equals(library.version()) - && library.getDownload() == null) { - manifest = manifest.withLibraries(libraries.subList(1, libraries.size())); - } - } - - return manifest; - } - - public static GameInstanceManifest maintainPreservingPatches(GameRepository repository, GameInstanceManifest manifest) { - if (!manifest.isResolvedPreservingPatches()) - throw new IllegalArgumentException("MaintainTask requires independent game version"); - GameInstanceManifest newVersion = maintain(repository, manifest.resolve(repository)); - return newVersion.withPatches(manifest.getPatches()); - } - - private static GameInstanceManifest maintainGameWithLaunchWrapper(GameRepository repository, GameInstanceManifest manifest, boolean reorderTweakClass) { - LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(manifest, null); - GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); - String mainClass = null; - - // Installing Forge will override the Minecraft arguments in json, so LiteLoader and OptiFine Tweaker are being re-added. - if (libraryAnalyzer.has(LITELOADER) && !libraryAnalyzer.hasModLauncher()) { - builder.replaceTweakClass(LibraryAnalyzer.LITELOADER_TWEAKER, LibraryAnalyzer.LITELOADER_TWEAKER, !reorderTweakClass, reorderTweakClass); - } else { - builder.removeTweakClass(LibraryAnalyzer.LITELOADER_TWEAKER); - } - - if (libraryAnalyzer.has(OPTIFINE)) { - if (!libraryAnalyzer.has(LITELOADER) && !libraryAnalyzer.has(FORGE)) { - if (builder.hasTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[1])) { - builder.replaceTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[1], LibraryAnalyzer.OPTIFINE_TWEAKERS[0], !reorderTweakClass, reorderTweakClass); - } - } else { - if (libraryAnalyzer.hasModLauncher()) { - // If ModLauncher installed, we use ModLauncher in place of LaunchWrapper. - mainClass = LibraryAnalyzer.MOD_LAUNCHER_MAIN; - for (String optiFineTweaker : LibraryAnalyzer.OPTIFINE_TWEAKERS) { - builder.removeTweakClass(optiFineTweaker); - } - } else if (builder.hasTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[0])) { - // If forge or LiteLoader installed, OptiFine Forge Tweaker is needed. - builder.replaceTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[0], LibraryAnalyzer.OPTIFINE_TWEAKERS[1], !reorderTweakClass, reorderTweakClass); - } - - } - } else { - for (String optiFineTweaker : LibraryAnalyzer.OPTIFINE_TWEAKERS) { - builder.removeTweakClass(optiFineTweaker); - } - } - - boolean hasForge = libraryAnalyzer.has(FORGE), hasModLauncher = libraryAnalyzer.hasModLauncher(); - for (String forgeTweaker : LibraryAnalyzer.FORGE_TWEAKERS) { - if (!hasForge) { - builder.removeTweakClass(forgeTweaker); - } else if (!hasModLauncher && builder.hasTweakClass(forgeTweaker)) { - builder.replaceTweakClass(forgeTweaker, forgeTweaker, !reorderTweakClass, reorderTweakClass); - } - } - - GameInstanceManifest ret = builder.build(); - return mainClass == null ? ret : ret.withMainClass(mainClass); - } - - private static GameInstanceManifest maintainGameWithCpwModLauncher(GameRepository repository, GameInstanceManifest manifest) { - LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(manifest, null); - GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); - - if (!libraryAnalyzer.has(FORGE)) return manifest; - - if (libraryAnalyzer.has(OPTIFINE)) { - Library hmclTransformerDiscoveryService = new Library(new Artifact("org.jackhuang.hmcl", "transformer-discovery-service", "1.0")); - Optional optiFine = manifest.getLibraries().stream().filter(library -> library.is("optifine", "OptiFine")).findAny(); - boolean libraryExisting = manifest.getLibraries().stream().anyMatch(library -> library.is("org.jackhuang.hmcl", "transformer-discovery-service")); - optiFine.ifPresent(library -> { - builder.addJvmArgument("-Dhmcl.transformer.candidates=${library_directory}/" + library.getPath()); - if (!libraryExisting) builder.addLibrary(hmclTransformerDiscoveryService); - Path libraryPath = repository.getLayout().getLibraryFile(manifest.id(), hmclTransformerDiscoveryService); - try (InputStream input = MaintainTask.class.getResourceAsStream("/assets/game/HMCLTransformerDiscoveryService-1.0.jar")) { - Files.createDirectories(libraryPath.getParent()); - Files.copy(Objects.requireNonNull(input, "Bundled HMCLTransformerDiscoveryService is missing."), libraryPath, StandardCopyOption.REPLACE_EXISTING); - } catch (IOException | NullPointerException e) { - LOG.warning("Unable to unpack HMCLTransformerDiscoveryService", e); - } - }); - } - - return builder.build(); - } - - private static String updateIgnoreList(GameRepository repository, GameInstanceManifest manifest, String ignoreList) { - String[] ignores = ignoreList.split(","); - List newIgnoreList = new ArrayList<>(); - - // To resolve the problem that name of primary jar may conflict with the module naming convention, - // we need to manually ignore ${primary_jar}. - newIgnoreList.add("${primary_jar}"); - - Path libraryDirectory = repository.getLayout().getLibrariesDirectory().toAbsolutePath().normalize(); - - // The default ignoreList is too loose and may cause some problems, we replace them with the absolute version. - // For example, if "client-extra" is in ignoreList, and game directory contains "client-extra" component, all - // libraries will be ignored, which is not expected. - for (String classpathName : repository.getClasspath(manifest)) { - Path classpathFile = Paths.get(classpathName).toAbsolutePath(); - String fileName = classpathFile.getFileName().toString(); - if (Stream.of(ignores).anyMatch(fileName::contains)) { - // This library should be ignored for Jigsaw module finding by Forge. - String absolutePath; - if (classpathFile.startsWith(libraryDirectory)) { - // Note: It's assumed using "/" instead of File.separator in classpath - absolutePath = "${library_directory}${file_separator}" + libraryDirectory.relativize(classpathFile).toString().replace(File.separator, "${file_separator}"); - } else { - absolutePath = classpathFile.toString(); - } - newIgnoreList.add(StringUtils.substringBefore(absolutePath, ",")); - } - } - return String.join(",", newIgnoreList); - } - - // Fix wrong configurations when launching 1.17+ with Forge. - private static GameInstanceManifest maintainGameWithCpwBoostrapLauncher(GameRepository repository, GameInstanceManifest manifest) { - LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(manifest, null); - GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); - - if (!libraryAnalyzer.has(FORGE) && !libraryAnalyzer.has(NEO_FORGE)) return manifest; - - Optional bslVersion = libraryAnalyzer.getVersion(BOOTSTRAP_LAUNCHER); - - if (bslVersion.isPresent()) { - if (VersionNumber.compare(bslVersion.get(), "0.1.17") < 0) { - // The default ignoreList will be applied to all components of libraries in classpath, - // so if game directory located in some directory like /Users/asm, all libraries will be ignored, - // which is not expected. We fix this here. - List jvm = builder.getMutableJvmArguments(); - for (int i = 0; i < jvm.size(); i++) { - Argument jvmArg = jvm.get(i); - if (jvmArg instanceof StringArgument) { - String jvmArgStr = jvmArg.toString(); - if (jvmArgStr.startsWith("-DignoreList=")) { - jvm.set(i, new StringArgument("-DignoreList=" + updateIgnoreList(repository, manifest, jvmArgStr.substring("-DignoreList=".length())))); - } - } - } - } else { - // bootstraplauncher 0.1.17 will only apply ignoreList to file name of libraries in classpath. - // So we only fixes name of primary jar. - List jvm = builder.getMutableJvmArguments(); - for (int i = 0; i < jvm.size(); i++) { - Argument jvmArg = jvm.get(i); - if (jvmArg instanceof StringArgument) { - String jvmArgStr = jvmArg.toString(); - if (jvmArgStr.startsWith("-DignoreList=")) { - jvm.set(i, new StringArgument(jvmArgStr + ",${primary_jar_name}")); - } - } - } - } - } - - return builder.build(); - } - - private static GameInstanceManifest maintainOptiFineLibrary(GameRepository repository, GameInstanceManifest manifest, boolean remove) { - LibraryAnalyzer libraryAnalyzer = LibraryAnalyzer.analyze(manifest, null); - List libraries = new ArrayList<>(manifest.getLibraries()); - - if (libraryAnalyzer.has(OPTIFINE)) { - if (libraryAnalyzer.has(LITELOADER) || libraryAnalyzer.has(FORGE)) { - // If forge or LiteLoader installed, OptiFine Forge Tweaker is needed. - // And we should load the installer jar instead of patch jar. - if (repository != null) { - for (int i = 0; i < manifest.getLibraries().size(); ++i) { - Library library = libraries.get(i); - if (library.is("optifine", "OptiFine")) { - Library newLibrary = new Library(new Artifact("optifine", "OptiFine", library.version(), "installer")); - if (Files.exists(repository.getLayout().getLibraryFile(manifest.id(), newLibrary))) { - libraries.set(i, null); - // OptiFine should be loaded after Forge in classpath. - // Although we have altered priority of OptiFine higher than Forge, - // there still exists a situation that Forge is installed without patch. - // Here we manually alter the position of OptiFine library in classpath. - if (!remove) libraries.add(newLibrary); - } - } - - if (library.is("optifine", "launchwrapper-of")) { - // With MinecraftForge installed, the custom launchwrapper installed by OptiFine will conflicts - // with the one installed by MinecraftForge or LiteLoader or ModLoader. - // Simply removing it works. - libraries.set(i, null); - } - } - } - } - } - - return manifest.withLibraries(libraries.stream().filter(Objects::nonNull).collect(Collectors.toList())); - } - - public static GameInstanceManifest unique(GameInstanceManifest manifest) { - List libraries = new ArrayList<>(); - - SimpleMultimap> multimap = new SimpleMultimap<>(HashMap::new, ArrayList::new); - - for (Library library : manifest.getLibraries()) { - String id = library.groupId() + ":" + library.artifactId(); - VersionNumber number = VersionNumber.asVersion(library.version()); - String serialized = JsonUtils.GSON.toJson(library); - - if (multimap.containsKey(id)) { - boolean duplicate = false; - for (int otherLibraryIndex : multimap.get(id)) { - Library otherLibrary = libraries.get(otherLibraryIndex); - VersionNumber otherNumber = VersionNumber.asVersion(otherLibrary.version()); - if (CompatibilityRule.equals(library.rules(), otherLibrary.rules())) { // rules equal, ignore older version. - boolean flag = true; - if (number.compareTo(otherNumber) > 0) { // if this library is newer - // replace [otherLibrary] with [library] - libraries.set(otherLibraryIndex, library); - } else if (number.compareTo(otherNumber) == 0) { // same library id. - // prevent from duplicated libraries - if (library.equals(otherLibrary)) { - String otherSerialized = JsonUtils.GSON.toJson(otherLibrary); - // A trick, the library that has more information is better, which can be - // considered whose serialized JSON text will be longer. - if (serialized.length() > otherSerialized.length()) { - libraries.set(otherLibraryIndex, library); - } - } else { - // for text2speech, which have same library id as well as version number, - // but its library and native library does not equal - flag = false; - } - } - if (flag) { - duplicate = true; - break; - } - } - } - - if (!duplicate) { - multimap.put(id, libraries.size()); - libraries.add(library); - } - } else { - multimap.put(id, libraries.size()); - libraries.add(library); - } - } - - return manifest.withLibraries(libraries); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java index 3e98f121031..f466f0fb50e 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 @@ -19,7 +19,6 @@ import org.jackhuang.hmcl.download.AbstractDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.download.MaintainTask; import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameRepository; @@ -136,6 +135,7 @@ private static boolean shouldDownloadFMLLib(FMLLib fmlLib, Path file) { } } + /// {@inheritDoc} @Override public void execute() throws IOException { int progress = 0; @@ -177,13 +177,26 @@ public void execute() throws IOException { throw new IOException("Cannot fix optifine", e); } } - } else if ("org.jackhuang.hmcl".equals(library.groupId()) && "mmc-bootstrap".equals(library.artifactId())) { + } else if ("org.jackhuang.hmcl".equals(library.groupId()) + && "mmc-bootstrap".equals(library.artifactId())) { if (!Files.exists(file)) { - try (InputStream input = MaintainTask.class.getResourceAsStream("/assets/game/HMCLMultiMCBootstrap-1.0.jar")) { + try (InputStream input = Objects.requireNonNull( + GameLibrariesTask.class.getResourceAsStream( + "/assets/game/HMCLMultiMCBootstrap-1.0.jar"), + "Bundled HMCLMultiMCBootstrap is missing.")) { Files.createDirectories(file.getParent()); - Files.copy(Objects.requireNonNull(input, "Bundled HMCLMultiMCBootstrap is missing."), file, StandardCopyOption.REPLACE_EXISTING); + Files.copy(input, file, StandardCopyOption.REPLACE_EXISTING); } } + } else if ("org.jackhuang.hmcl".equals(library.groupId()) + && "transformer-discovery-service".equals(library.artifactId())) { + try (InputStream input = Objects.requireNonNull( + GameLibrariesTask.class.getResourceAsStream( + "/assets/game/HMCLTransformerDiscoveryService-1.0.jar"), + "Bundled HMCLTransformerDiscoveryService is missing.")) { + Files.createDirectories(file.getParent()); + Files.copy(input, file, StandardCopyOption.REPLACE_EXISTING); + } } if (shouldDownloadLibrary(gameRepository, manifest, library, integrityCheck) && (library.hasDownloadURL() || !"optifine".equals(library.groupId()))) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 3d2819321c1..d81562675f0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -24,7 +24,6 @@ import javafx.beans.property.ReadOnlyLongWrapper; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; -import org.jackhuang.hmcl.download.MaintainTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -567,25 +566,28 @@ public Path getInstanceJson(GameInstanceID instanceId) { return getLayout().getInstanceJson(instanceId); } + /// Saves a stored manifest without applying derived launch-view normalization. + /// + /// The returned task writes the manifest and publishes a snapshot containing exactly that + /// persistent representation, including its inheritance and pending patches. + /// + /// @param instanceManifest the persistent manifest to save + /// @return the task that saves and publishes the manifest public Task saveAsync(GameInstanceManifest instanceManifest) { return Task.supplyAsync(() -> { - GameInstanceManifest savedManifest = instanceManifest.isResolvedPreservingPatches() - ? MaintainTask.maintainPreservingPatches(this, instanceManifest) - : instanceManifest; - - Path json = getInstanceJson(savedManifest.id()).toAbsolutePath(); + Path json = getInstanceJson(instanceManifest.id()).toAbsolutePath(); Files.createDirectories(json.getParent()); - JsonUtils.writeToJsonFile(json, savedManifest); + JsonUtils.writeToJsonFile(json, instanceManifest); DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); - DefaultGameInstance existing = newSnapshot.get(savedManifest.id()); + DefaultGameInstance existing = newSnapshot.get(instanceManifest.id()); if (existing != null) { - newSnapshot.put(existing.withManifest(newSnapshot, savedManifest)); + newSnapshot.put(existing.withManifest(newSnapshot, instanceManifest)); } else { - newSnapshot.put(createInstance(newSnapshot, savedManifest.id(), savedManifest)); + newSnapshot.put(createInstance(newSnapshot, instanceManifest.id(), instanceManifest)); } publishSnapshot(newSnapshot); - return savedManifest; + return instanceManifest; }); } 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 aa1f2f6b60a..e77506e795b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -233,23 +233,28 @@ public DefaultGameRepositorySnapshot clone() { return newSnapshot; } - /// Resolves official-layout inheritance and patches into launch and standalone views. + /// Resolves official-layout inheritance and patches, then normalizes the final launch view. /// /// @param manifest the manifest to resolve /// @return the resolved manifest views /// @throws NoSuchGameInstanceException if an inherited parent is missing from this snapshot public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { - return resolve(manifest, new HashSet<>()); + GameInstanceManifest.Resolved resolved = resolveStructure(manifest, new HashSet<>()); + GameInstanceManifest normalizedLaunchManifest = + LaunchManifestNormalizer.normalize(resolved.launchManifest()); + return new GameInstanceManifest.Resolved( + resolved.unresolved(), normalizedLaunchManifest, resolved.standaloneManifest()); } - /// Resolves official-layout inheritance and patches into launch and standalone views. + /// Resolves official-layout inheritance and patches without launch compatibility normalization. /// /// @param manifest the manifest to resolve /// @param resolvedSoFar instance ids already visited in the inheritance chain /// @return the resolved manifest views /// @throws NoSuchGameInstanceException if an inherited parent is missing from this snapshot - public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, - Set resolvedSoFar) throws NoSuchGameInstanceException { + private GameInstanceManifest.Resolved resolveStructure( + GameInstanceManifest manifest, + Set resolvedSoFar) throws NoSuchGameInstanceException { GameInstanceManifest launchManifest; GameInstanceManifest standaloneManifest = manifest.isRoot() ? manifest @@ -280,7 +285,8 @@ public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest, } // It is supposed to auto-install a version in getVersion. - GameInstanceManifest.Resolved parentResolved = resolve(parentInstance.getManifest(), resolvedSoFar); + GameInstanceManifest.Resolved parentResolved = + resolveStructure(parentInstance.getManifest(), resolvedSoFar); launchManifest = manifest.merge(parentResolved.launchManifest()); standaloneManifest = addPatches( addPatches(parentResolved.standaloneManifest(), Collections.singleton(manifest.toPatch())), diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java index 4dca673a317..f0919a3b461 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java @@ -65,8 +65,9 @@ public record GameInstanceManifest( /// Resolved manifest views with inheritance folded. /// - /// @param launchManifest the final manifest data used by launch-time consumers - /// @param standaloneManifest the standalone manifest data with pending patches preserved + /// @param unresolved the stored manifest supplied to resolution + /// @param launchManifest the normalized final manifest data used by launch-time consumers + /// @param standaloneManifest the structural standalone manifest with pending patches preserved @NotNullByDefault public record Resolved(GameInstanceManifest unresolved, GameInstanceManifest launchManifest, @@ -340,13 +341,6 @@ public boolean isRoot() { return root != null && root; } - /// Returns whether this manifest is already a standalone view. - /// - /// @return whether this manifest has no parent - public boolean isResolvedPreservingPatches() { - return inheritsFrom == null; - } - /// Returns the pending patches. /// /// @return the pending patches, or an empty list when absent diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java index 3fc24b56b14..6df992bef96 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -63,7 +63,7 @@ default Path getBaseDirectory() { /// @return the current repository snapshot GameRepositorySnapshot getSnapshot(); - /// Resolves inheritance into launch and standalone manifest views. + /// Resolves inheritance into a normalized launch view and a patch-preserving standalone view. /// /// @param manifest the manifest to resolve /// @return the resolved manifest view diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java new file mode 100644 index 00000000000..2f7fe8a0b81 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -0,0 +1,302 @@ +/* + * 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.LibraryAnalyzer; +import org.jackhuang.hmcl.util.SimpleMultimap; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; + +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.BOOTSTRAP_LAUNCHER; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.FORGE; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.LITELOADER; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.NEO_FORGE; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.OPTIFINE; + +/// Normalizes a structurally resolved manifest into the stable view consumed by launch-time code. +/// +/// Normalization depends only on manifest content. Filesystem-dependent compatibility adjustments +/// are performed separately immediately before launch. +@NotNullByDefault +public final class LaunchManifestNormalizer { + /// Prevents construction of this utility class. + private LaunchManifestNormalizer() { + } + + /// Normalizes a resolved launch manifest. + /// + /// The input must not contain inheritance or pending patches. The returned manifest has duplicate + /// libraries removed and loader-specific arguments and libraries repaired. The input is unchanged. + /// + /// @param manifest the structurally resolved launch manifest + /// @return the normalized launch manifest + /// @throws IllegalArgumentException if the manifest still contains inheritance or pending patches + public static GameInstanceManifest normalize(GameInstanceManifest manifest) { + if (manifest.inheritsFrom() != null || !manifest.getPatches().isEmpty()) { + throw new IllegalArgumentException("Launch manifest must be structurally resolved"); + } + + GameInstanceManifest normalized = uniqueLibraries(manifest); + @Nullable String mainClass = normalized.mainClass(); + + if (LibraryAnalyzer.LAUNCH_WRAPPER_MAIN.equals(mainClass)) { + normalized = normalizeLaunchWrapper(normalized, true); + if (LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(normalized.mainClass())) { + normalized = normalizeModLauncher(normalized); + } + } else if (LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(mainClass)) { + normalized = normalizeModLauncher(normalized); + } else if (LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(mainClass)) { + normalized = normalizeBootstrapLauncher(normalized); + } + + return removeLegacyLog4jPatch(normalized); + } + + /// Repairs LaunchWrapper tweak-class configuration. + /// + /// @param manifest the resolved manifest + /// @param reorderTweakClass whether retained tweak classes are moved to their required positions + /// @return the repaired manifest + private static GameInstanceManifest normalizeLaunchWrapper( + GameInstanceManifest manifest, + boolean reorderTweakClass) { + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); + GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); + @Nullable String mainClass = null; + + // Forge installers may replace the complete argument list, so compatible tweakers must be + // restored in deterministic order. + if (analyzer.has(LITELOADER) && !analyzer.hasModLauncher()) { + builder.replaceTweakClass( + LibraryAnalyzer.LITELOADER_TWEAKER, + LibraryAnalyzer.LITELOADER_TWEAKER, + !reorderTweakClass, + reorderTweakClass); + } else { + builder.removeTweakClass(LibraryAnalyzer.LITELOADER_TWEAKER); + } + + if (analyzer.has(OPTIFINE)) { + if (!analyzer.has(LITELOADER) && !analyzer.has(FORGE)) { + if (builder.hasTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[1])) { + builder.replaceTweakClass( + LibraryAnalyzer.OPTIFINE_TWEAKERS[1], + LibraryAnalyzer.OPTIFINE_TWEAKERS[0], + !reorderTweakClass, + reorderTweakClass); + } + } else if (analyzer.hasModLauncher()) { + mainClass = LibraryAnalyzer.MOD_LAUNCHER_MAIN; + for (String optiFineTweaker : LibraryAnalyzer.OPTIFINE_TWEAKERS) { + builder.removeTweakClass(optiFineTweaker); + } + } else if (builder.hasTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[0])) { + builder.replaceTweakClass( + LibraryAnalyzer.OPTIFINE_TWEAKERS[0], + LibraryAnalyzer.OPTIFINE_TWEAKERS[1], + !reorderTweakClass, + reorderTweakClass); + } + } else { + for (String optiFineTweaker : LibraryAnalyzer.OPTIFINE_TWEAKERS) { + builder.removeTweakClass(optiFineTweaker); + } + } + + boolean hasForge = analyzer.has(FORGE); + boolean hasModLauncher = analyzer.hasModLauncher(); + for (String forgeTweaker : LibraryAnalyzer.FORGE_TWEAKERS) { + if (!hasForge) { + builder.removeTweakClass(forgeTweaker); + } else if (!hasModLauncher && builder.hasTweakClass(forgeTweaker)) { + builder.replaceTweakClass( + forgeTweaker, + forgeTweaker, + !reorderTweakClass, + reorderTweakClass); + } + } + + GameInstanceManifest normalized = builder.build(); + return mainClass == null ? normalized : normalized.withMainClass(mainClass); + } + + /// Adds the transformer discovery service required by Forge and OptiFine on ModLauncher. + /// + /// @param manifest the resolved manifest + /// @return the repaired manifest + private static GameInstanceManifest normalizeModLauncher(GameInstanceManifest manifest) { + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); + if (!analyzer.has(FORGE) || !analyzer.has(OPTIFINE)) { + return manifest; + } + + GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); + Library transformerDiscoveryService = new Library( + new Artifact("org.jackhuang.hmcl", "transformer-discovery-service", "1.0")); + boolean servicePresent = manifest.getLibraries().stream() + .anyMatch(library -> library.is("org.jackhuang.hmcl", "transformer-discovery-service")); + + manifest.getLibraries().stream() + .filter(library -> library.is("optifine", "OptiFine")) + .findAny() + .ifPresent(optiFine -> { + String candidateArgument = + "-Dhmcl.transformer.candidates=${library_directory}/" + optiFine.getPath(); + List jvmArguments = builder.getMutableJvmArguments(); + if (jvmArguments.stream().noneMatch(argument -> candidateArgument.equals(argument.toString()))) { + jvmArguments.add(new StringArgument(candidateArgument)); + } + if (!servicePresent) { + builder.addLibrary(transformerDiscoveryService); + } + }); + + return builder.build(); + } + + /// Repairs the filesystem-independent BootstrapLauncher ignore-list form. + /// + /// BootstrapLauncher 0.1.17 and newer compare ignore-list entries only with file names, so the + /// primary jar placeholder can be added without inspecting the installed classpath. + /// + /// @param manifest the resolved manifest + /// @return the repaired manifest + private static GameInstanceManifest normalizeBootstrapLauncher(GameInstanceManifest manifest) { + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); + if (!analyzer.has(FORGE) && !analyzer.has(NEO_FORGE)) { + return manifest; + } + + if (analyzer.getVersion(BOOTSTRAP_LAUNCHER) + .filter(version -> VersionNumber.compare(version, "0.1.17") >= 0) + .isEmpty()) { + return manifest; + } + + GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); + List jvmArguments = builder.getMutableJvmArguments(); + for (int i = 0; i < jvmArguments.size(); i++) { + Argument argument = jvmArguments.get(i); + if (argument instanceof StringArgument) { + String value = argument.toString(); + if (value.startsWith("-DignoreList=") + && !containsCommaSeparatedValue( + value.substring("-DignoreList=".length()), "${primary_jar_name}")) { + jvmArguments.set(i, new StringArgument(value + ",${primary_jar_name}")); + } + } + } + return builder.build(); + } + + /// Returns whether a comma-separated list contains the exact requested value. + /// + /// @param values the comma-separated values + /// @param target the value to find + /// @return whether `target` is present + private static boolean containsCommaSeparatedValue(String values, String target) { + for (String value : values.split(",")) { + if (target.equals(value)) { + return true; + } + } + return false; + } + + /// Removes the obsolete HMCL Log4j patch formerly prepended to affected manifests. + /// + /// @param manifest the normalized manifest + /// @return the manifest without the obsolete first library, when present + private static GameInstanceManifest removeLegacyLog4jPatch(GameInstanceManifest manifest) { + List libraries = manifest.getLibraries(); + if (libraries.isEmpty()) { + return manifest; + } + + Library library = libraries.get(0); + if ("org.glavo".equals(library.groupId()) + && ("log4j-patch".equals(library.artifactId()) + || "log4j-patch-beta9".equals(library.artifactId())) + && "1.0".equals(library.version())) { + return manifest.withLibraries(libraries.subList(1, libraries.size())); + } + return manifest; + } + + /// Removes redundant library declarations while retaining rule-distinct variants. + /// + /// For equal compatibility rules, the newer version wins. Identical coordinates retain the + /// declaration with the richer serialized metadata. + /// + /// @param manifest the resolved manifest + /// @return the manifest with redundant libraries removed + private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifest) { + List libraries = new ArrayList<>(); + SimpleMultimap> indexes = + new SimpleMultimap<>(HashMap::new, ArrayList::new); + + for (Library library : manifest.getLibraries()) { + String id = library.groupId() + ":" + library.artifactId(); + VersionNumber version = VersionNumber.asVersion(library.version()); + String serialized = JsonUtils.GSON.toJson(library); + + if (!indexes.containsKey(id)) { + indexes.put(id, libraries.size()); + libraries.add(library); + continue; + } + + boolean duplicate = false; + for (int otherIndex : indexes.get(id)) { + Library other = libraries.get(otherIndex); + if (!CompatibilityRule.equals(library.rules(), other.rules())) { + continue; + } + + int comparison = version.compareTo(VersionNumber.asVersion(other.version())); + if (comparison > 0) { + libraries.set(otherIndex, library); + } else if (comparison == 0 && library.equals(other)) { + String otherSerialized = JsonUtils.GSON.toJson(other); + if (serialized.length() > otherSerialized.length()) { + libraries.set(otherIndex, library); + } + } else if (comparison == 0) { + continue; + } + duplicate = true; + break; + } + + if (!duplicate) { + indexes.put(id, libraries.size()); + libraries.add(library); + } + } + + return manifest.withLibraries(libraries); + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java index 0625665f018..5acda8e2609 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 @@ -20,7 +20,6 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.download.MaintainTask; import org.jackhuang.hmcl.download.game.GameAssetDownloadTask; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameLibrariesTask; @@ -35,6 +34,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.io.InputStream; @@ -230,10 +230,11 @@ public List> getDependents() { return dependents; } + /// {@inheritDoc} @Override public void execute() throws Exception { // Stage #3: Build Json-Patch artifact. - MultiMCInstancePatch.ResolvedInstance artifact = null; + @Nullable MultiMCInstancePatch.ResolvedInstance artifact = null; for (int i = dependents.size() - 1; i >= 0; i--) { Task task = dependents.get(i); if (task instanceof MMCInstancePatchesAssembleTask) { @@ -264,14 +265,17 @@ public void execute() throws Exception { } } - try (InputStream input = MaintainTask.class.getResourceAsStream("/assets/game/HMCLMultiMCBootstrap-1.0.jar")) { + try (InputStream input = Objects.requireNonNull( + MultiMCModpackInstallTask.class.getResourceAsStream( + "/assets/game/HMCLMultiMCBootstrap-1.0.jar"), + "Bundled HMCLMultiMCBootstrap is missing.")) { Path libraryPath = repository.getLayout().getLibraryFile(artifact.getManifest().id(), MultiMCInstancePatch.BOOTSTRAP_LIBRARY); Files.createDirectories(libraryPath.getParent()); - Files.copy(Objects.requireNonNull(input, "Bundled HMCLMultiMCBootstrap is missing."), libraryPath, StandardCopyOption.REPLACE_EXISTING); + Files.copy(input, libraryPath, StandardCopyOption.REPLACE_EXISTING); } - String iconKey = this.manifest.getIconKey(); + @Nullable String iconKey = this.manifest.getIconKey(); if (iconKey != null) { Path iconFile = root.resolve(iconKey + ".png"); if (Files.exists(iconFile)) { 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 39eff7b6549..c76508563bf 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -19,6 +19,8 @@ import org.jackhuang.hmcl.download.DefaultCacheRepository; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.LaunchManifestPreparation; +import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.MojangDownloadProvider; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameVerificationFixTask; @@ -55,6 +57,110 @@ @NotNullByDefault public final class DefaultGameInstanceTest { + /// Resolve normalizes only the derived launch view and leaves the stored patch structure intact. + @Test + public void testResolveNormalizesLaunchWithoutChangingStoredPatches(@TempDir Path tempDirectory) { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + Library oldLibrary = new Library(new Artifact("example", "library", "1.0")); + Library newLibrary = new Library(new Artifact("example", "library", "2.0")); + List patches = List.of(new GameInstancePatch( + "loader", null, 0, null, null, List.of(oldLibrary, newLibrary))); + GameInstanceManifest storedManifest = new GameInstanceManifest(instanceId) + .withRoot(true) + .withPatches(patches); + TestGameInstance instance = repository.publish(instanceId, storedManifest); + + GameInstanceManifest.Resolved resolved = instance.getResolvedManifest(); + + assertEquals(1, resolved.launchManifest().getLibraries().size()); + assertEquals("2.0", resolved.launchManifest().getLibraries().getFirst().version()); + assertEquals(patches, resolved.standaloneManifest().getPatches()); + assertEquals(storedManifest, instance.getManifest()); + assertEquals( + resolved.launchManifest(), + LaunchManifestNormalizer.normalize(resolved.launchManifest())); + } + + /// ModLauncher normalization adds support metadata without materializing bundled files. + @Test + public void testModLauncherNormalizationDoesNotWriteBundledLibraries(@TempDir Path tempDirectory) { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceManifest manifest = new GameInstanceManifest(instanceId) + .withMainClass(LibraryAnalyzer.MOD_LAUNCHER_MAIN) + .withLibraries(List.of( + new Library(new Artifact("net.minecraftforge", "forge", "1.0")), + new Library(new Artifact("optifine", "OptiFine", "1.0")))); + TestGameInstance instance = repository.publish(instanceId, manifest); + GameInstanceManifest launchManifest = instance.getResolvedManifest().launchManifest(); + Library transformerService = launchManifest.getLibraries().stream() + .filter(library -> library.is( + "org.jackhuang.hmcl", "transformer-discovery-service")) + .findAny() + .orElseThrow(); + Path transformerFile = repository.getLayout().getLibraryFile(instanceId, transformerService); + + assertFalse(Files.exists(transformerFile)); + assertEquals(launchManifest, LaunchManifestNormalizer.normalize(launchManifest)); + } + + /// Launch preparation selects an installed OptiFine installer without changing the resolved view. + @Test + public void testLaunchPreparationSelectsInstalledOptiFine(@TempDir Path tempDirectory) + throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + GameInstanceManifest manifest = new GameInstanceManifest(instanceId) + .withMainClass(LibraryAnalyzer.LAUNCH_WRAPPER_MAIN) + .withLibraries(List.of( + new Library(new Artifact("net.minecraftforge", "forge", "1.0")), + new Library(new Artifact("optifine", "OptiFine", "1.0")), + new Library(new Artifact("optifine", "launchwrapper-of", "2.0")))); + GameInstanceManifest launchManifest = repository.publish(instanceId, manifest) + .getResolvedManifest() + .launchManifest(); + Library installer = new Library(new Artifact("optifine", "OptiFine", "1.0", "installer")); + Path installerFile = repository.getLayout().getLibraryFile(instanceId, installer); + Files.createDirectories(installerFile.getParent()); + Files.write(installerFile, new byte[]{1}); + + GameInstanceManifest prepared = LaunchManifestPreparation.prepare(repository, launchManifest); + + assertTrue(prepared.getLibraries().stream() + .anyMatch(library -> library.is("optifine", "OptiFine") + && "installer".equals(library.classifier()))); + assertFalse(prepared.getLibraries().stream() + .anyMatch(library -> library.is("optifine", "launchwrapper-of"))); + assertTrue(launchManifest.getLibraries().stream() + .anyMatch(library -> library.is("optifine", "OptiFine") + && library.classifier() == null)); + } + + /// Saving a manifest preserves its root flag and pending patches without baking in normalization. + @Test + public void testSavePreservesManifestPatchStructure(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + List patches = List.of(new GameInstancePatch( + "loader", + null, + 0, + null, + null, + List.of(new Library(new Artifact("example", "library", "1.0"))))); + GameInstanceManifest manifest = new GameInstanceManifest(instanceId) + .withRoot(true) + .withPatches(patches); + + repository.saveAsync(manifest).run(); + + GameInstanceManifest savedManifest = repository.getInstance(instanceId).getManifest(); + assertTrue(savedManifest.isRoot()); + assertEquals(patches, savedManifest.getPatches()); + assertTrue(savedManifest.getLibraries().isEmpty()); + } + /// Asset and modpack paths are resolved directly from the owning instance. @Test public void testInstanceOwnsAssetAndModpackPaths(@TempDir Path tempDirectory) throws IOException { From 900448f3155efcedc962e6c4788139414449f84b Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 11:30:21 +0800 Subject: [PATCH 068/199] refactor(GameLibrariesTask, LaunchManifestPreparation): enhance library handling and manifest preparation for improved launch compatibility --- .../download/LaunchManifestPreparation.java | 56 +----------- .../hmcl/launch/DefaultLauncher.java | 2 +- .../hmcl/launch/LaunchClasspathResolver.java | 89 +++++++++++++++++++ .../hmcl/game/DefaultGameInstanceTest.java | 77 +++++++++++++--- 4 files changed, 160 insertions(+), 64 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java index 008f385e651..27d29b07381 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java @@ -18,19 +18,15 @@ package org.jackhuang.hmcl.download; import org.jackhuang.hmcl.game.Argument; -import org.jackhuang.hmcl.game.Artifact; import org.jackhuang.hmcl.game.GameInstanceLibraryBuilder; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.game.Library; import org.jackhuang.hmcl.game.StringArgument; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; import java.io.File; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; @@ -39,11 +35,9 @@ import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.BOOTSTRAP_LAUNCHER; import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.FORGE; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.LITELOADER; import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.NEO_FORGE; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.OPTIFINE; -/// Applies launch-manifest compatibility adjustments that depend on the installed filesystem. +/// Applies launch-manifest argument adjustments that depend on the installed filesystem. @NotNullByDefault public final class LaunchManifestPreparation { /// Prevents construction of this utility class. @@ -52,8 +46,8 @@ private LaunchManifestPreparation() { /// Prepares a normalized launch manifest using the current library files. /// - /// The input must not contain inheritance or pending patches. The returned manifest may select - /// a locally installed OptiFine artifact or replace an old BootstrapLauncher ignore list. + /// The input must not contain inheritance or pending patches. The returned manifest may replace + /// an old BootstrapLauncher ignore list but retains the input library list. /// /// @param repository the repository that owns the installed libraries /// @param manifest the normalized launch manifest @@ -66,11 +60,7 @@ public static GameInstanceManifest prepare( throw new IllegalArgumentException("Launch manifest must be structurally resolved"); } - GameInstanceManifest prepared = prepareBootstrapLauncher(repository, manifest); - if (!LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(prepared.mainClass())) { - prepared = prepareOptiFineLibrary(repository, prepared); - } - return prepared; + return prepareBootstrapLauncher(repository, manifest); } /// Replaces unsafe substring-based ignore-list entries used by old BootstrapLauncher versions. @@ -147,42 +137,4 @@ private static String updateIgnoreList( return String.join(",", exactEntries); } - /// Selects the locally installed OptiFine installer artifact required by other loaders. - /// - /// @param repository the repository that owns the installed libraries - /// @param manifest the normalized launch manifest - /// @return the adjusted manifest - private static GameInstanceManifest prepareOptiFineLibrary( - GameRepository repository, - GameInstanceManifest manifest) { - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); - if (!analyzer.has(OPTIFINE) || (!analyzer.has(LITELOADER) && !analyzer.has(FORGE))) { - return manifest; - } - - boolean removeFromClasspath = LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()); - List libraries = new ArrayList<>(); - @Nullable Library selectedInstaller = null; - - for (Library library : manifest.getLibraries()) { - if (library.is("optifine", "OptiFine")) { - Library installer = new Library( - new Artifact("optifine", "OptiFine", library.version(), "installer")); - if (Files.exists(repository.getLayout().getLibraryFile(manifest.id(), installer))) { - selectedInstaller = installer; - } else { - libraries.add(library); - } - } else if (library.is("optifine", "launchwrapper-of")) { - // This modified LaunchWrapper conflicts with Forge and LiteLoader. - } else { - libraries.add(library); - } - } - - if (!removeFromClasspath && selectedInstaller != null) { - libraries.add(selectedInstaller); - } - return manifest.withLibraries(libraries); - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 9cebffdf712..511f0754a5f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -275,7 +275,7 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { } } - Set classpath = instance.getRepository().getClasspath(manifest); + Set classpath = LaunchClasspathResolver.resolve(instance.getRepository(), manifest); if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) { classpath.removeIf(c -> c.contains("2.9.4-nightly-20150209")); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java new file mode 100644 index 00000000000..a68c08f3f37 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java @@ -0,0 +1,89 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.launch; + +import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.Artifact; +import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.GameRepository; +import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Set; + +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.FORGE; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.LITELOADER; +import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.OPTIFINE; + +/// Resolves the library classpath used for one launch attempt. +@NotNullByDefault +public final class LaunchClasspathResolver { + /// Prevents construction of this utility class. + private LaunchClasspathResolver() { + } + + /// Returns a mutable classpath containing installed libraries selected for this launch. + /// + /// For Forge or LiteLoader installations containing OptiFine, an installed OptiFine installer + /// artifact replaces the ordinary artifact. With ModLauncher, the installer is omitted from the + /// ordinary classpath because transformer discovery loads it separately. The incompatible + /// `launchwrapper-of` artifact is also omitted. + /// + /// @param repository the repository that owns the installed libraries + /// @param manifest the effective launch manifest + /// @return a mutable insertion-ordered set of absolute classpath entries + public static Set resolve( + GameRepository repository, + GameInstanceManifest manifest) { + Set classpath = new LinkedHashSet<>(repository.getClasspath(manifest)); + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); + if (!analyzer.has(OPTIFINE) || (!analyzer.has(LITELOADER) && !analyzer.has(FORGE))) { + return classpath; + } + + boolean removeFromClasspath = LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()); + @Nullable Path selectedInstallerFile = null; + + for (Library library : manifest.getLibraries()) { + Path libraryFile = repository.getLayout().getLibraryFile(manifest.id(), library); + if (library.is("optifine", "OptiFine")) { + Library installer = new Library( + new Artifact("optifine", "OptiFine", library.version(), "installer")); + Path installerFile = repository.getLayout().getLibraryFile(manifest.id(), installer); + if (Files.exists(installerFile)) { + classpath.remove(FileUtils.getAbsolutePath(libraryFile)); + selectedInstallerFile = installerFile; + } + } else if (library.is("optifine", "launchwrapper-of")) { + classpath.remove(FileUtils.getAbsolutePath(libraryFile)); + } + } + + if (!removeFromClasspath + && selectedInstallerFile != null + && Files.isRegularFile(selectedInstallerFile)) { + classpath.add(FileUtils.getAbsolutePath(selectedInstallerFile)); + } + return classpath; + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java index c76508563bf..f0de6accb01 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.download.MojangDownloadProvider; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameVerificationFixTask; +import org.jackhuang.hmcl.launch.LaunchClasspathResolver; import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask; import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask; import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask; @@ -41,6 +42,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; @@ -105,36 +107,89 @@ public void testModLauncherNormalizationDoesNotWriteBundledLibraries(@TempDir Pa assertEquals(launchManifest, LaunchManifestNormalizer.normalize(launchManifest)); } - /// Launch preparation selects an installed OptiFine installer without changing the resolved view. + /// Launch classpath resolution selects an installed OptiFine installer without changing the manifest. @Test - public void testLaunchPreparationSelectsInstalledOptiFine(@TempDir Path tempDirectory) + public void testLaunchClasspathSelectsInstalledOptiFine(@TempDir Path tempDirectory) throws IOException { TestRepository repository = new TestRepository(tempDirectory); GameInstanceID instanceId = new GameInstanceID("instance"); + Library forge = new Library(new Artifact("net.minecraftforge", "forge", "1.0")); + Library optiFine = new Library(new Artifact("optifine", "OptiFine", "1.0")); + Library optiFineLaunchWrapper = new Library( + new Artifact("optifine", "launchwrapper-of", "2.0")); GameInstanceManifest manifest = new GameInstanceManifest(instanceId) .withMainClass(LibraryAnalyzer.LAUNCH_WRAPPER_MAIN) - .withLibraries(List.of( - new Library(new Artifact("net.minecraftforge", "forge", "1.0")), - new Library(new Artifact("optifine", "OptiFine", "1.0")), - new Library(new Artifact("optifine", "launchwrapper-of", "2.0")))); + .withLibraries(List.of(forge, optiFine, optiFineLaunchWrapper)); GameInstanceManifest launchManifest = repository.publish(instanceId, manifest) .getResolvedManifest() .launchManifest(); Library installer = new Library(new Artifact("optifine", "OptiFine", "1.0", "installer")); + Path forgeFile = repository.getLayout().getLibraryFile(instanceId, forge); + Path optiFineFile = repository.getLayout().getLibraryFile(instanceId, optiFine); + Path optiFineLaunchWrapperFile = repository.getLayout() + .getLibraryFile(instanceId, optiFineLaunchWrapper); Path installerFile = repository.getLayout().getLibraryFile(instanceId, installer); + Files.createDirectories(forgeFile.getParent()); Files.createDirectories(installerFile.getParent()); + Files.createDirectories(optiFineLaunchWrapperFile.getParent()); + Files.write(forgeFile, new byte[]{1}); + Files.write(optiFineFile, new byte[]{1}); + Files.write(optiFineLaunchWrapperFile, new byte[]{1}); Files.write(installerFile, new byte[]{1}); GameInstanceManifest prepared = LaunchManifestPreparation.prepare(repository, launchManifest); + Set classpath = LaunchClasspathResolver.resolve(repository, prepared); + assertSame(launchManifest, prepared); + assertEquals(Set.of( + forgeFile.toAbsolutePath().toString(), + installerFile.toAbsolutePath().toString()), classpath); assertTrue(prepared.getLibraries().stream() - .anyMatch(library -> library.is("optifine", "OptiFine") - && "installer".equals(library.classifier()))); - assertFalse(prepared.getLibraries().stream() - .anyMatch(library -> library.is("optifine", "launchwrapper-of"))); - assertTrue(launchManifest.getLibraries().stream() .anyMatch(library -> library.is("optifine", "OptiFine") && library.classifier() == null)); + assertTrue(prepared.getLibraries().stream() + .anyMatch(library -> library.is("optifine", "launchwrapper-of"))); + } + + /// ModLauncher keeps an installed OptiFine installer outside its ordinary classpath. + @Test + public void testModLauncherClasspathOmitsInstalledOptiFine(@TempDir Path tempDirectory) + throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID instanceId = new GameInstanceID("instance"); + Library forge = new Library(new Artifact("net.minecraftforge", "forge", "1.0")); + Library optiFine = new Library(new Artifact("optifine", "OptiFine", "1.0")); + GameInstanceManifest manifest = new GameInstanceManifest(instanceId) + .withMainClass(LibraryAnalyzer.MOD_LAUNCHER_MAIN) + .withLibraries(List.of(forge, optiFine)); + GameInstanceManifest launchManifest = repository.publish(instanceId, manifest) + .getResolvedManifest() + .launchManifest(); + Library installer = new Library(new Artifact("optifine", "OptiFine", "1.0", "installer")); + Library transformerService = launchManifest.getLibraries().stream() + .filter(library -> library.is( + "org.jackhuang.hmcl", "transformer-discovery-service")) + .findAny() + .orElseThrow(); + Path forgeFile = repository.getLayout().getLibraryFile(instanceId, forge); + Path optiFineFile = repository.getLayout().getLibraryFile(instanceId, optiFine); + Path installerFile = repository.getLayout().getLibraryFile(instanceId, installer); + Path transformerServiceFile = repository.getLayout() + .getLibraryFile(instanceId, transformerService); + Files.createDirectories(forgeFile.getParent()); + Files.createDirectories(installerFile.getParent()); + Files.createDirectories(transformerServiceFile.getParent()); + Files.write(forgeFile, new byte[]{1}); + Files.write(optiFineFile, new byte[]{1}); + Files.write(installerFile, new byte[]{1}); + Files.write(transformerServiceFile, new byte[]{1}); + + Set classpath = LaunchClasspathResolver.resolve(repository, launchManifest); + + assertEquals(Set.of( + forgeFile.toAbsolutePath().toString(), + transformerServiceFile.toAbsolutePath().toString()), classpath); + assertTrue(launchManifest.getLibraries().contains(optiFine)); } /// Saving a manifest preserves its root flag and pending patches without baking in normalization. From 4530d56ab6dbafbedf02ec8a2484626a18b89d96 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 20:00:17 +0800 Subject: [PATCH 069/199] refactor(CurseInstallTask, GameRepository, ModpackInstallTasks): update modpack configuration retrieval to use new layout method --- .../org/jackhuang/hmcl/game/HMCLGameRepository.java | 10 ++++------ .../jackhuang/hmcl/game/HMCLModpackInstallTask.java | 4 ++-- .../hmcl/ui/download/ModpackInstallWizardProvider.java | 9 +++------ .../org/jackhuang/hmcl/game/DefaultGameRepository.java | 4 ---- .../hmcl/game/DefaultGameRepositoryLayout.java | 4 ++++ .../java/org/jackhuang/hmcl/game/GameInstanceID.java | 10 ++++++++-- .../java/org/jackhuang/hmcl/game/GameRepository.java | 10 ---------- .../jackhuang/hmcl/modpack/curse/CurseInstallTask.java | 4 ++-- .../modpack/mcbbs/McbbsModpackLocalInstallTask.java | 4 ++-- .../hmcl/modpack/modrinth/ModrinthInstallTask.java | 4 ++-- .../modpack/multimc/MultiMCModpackInstallTask.java | 6 +++--- .../modpack/server/ServerModpackLocalInstallTask.java | 4 ++-- .../modpack/server/ServerModpackRemoteInstallTask.java | 2 +- 13 files changed, 33 insertions(+), 42 deletions(-) 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 69e1888dbff..f87da7d1611 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -457,7 +457,7 @@ public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRun builder.setQuickPlayOption(quickPlayOption); } - Path json = getModpackConfiguration(instanceId); + Path json = getLayout().getModpackConfigurationFile(instanceId); if (Files.exists(json)) { try { String jsonText = Files.readString(json); @@ -475,11 +475,6 @@ public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRun return builder; } - @Override - public Path getModpackConfiguration(GameInstanceID instanceId) { - return getLayout().getInstanceRoot(instanceId).resolve("modpack.cfg"); - } - /// Marks the instance as a modpack for run-directory resolution during installation. /// /// @param instanceId the instance id @@ -501,6 +496,9 @@ public void undoMark(GameInstanceID instanceId) { private static final Set FORBIDDEN_INSTANCE_IDS = Set.of("modpack", "minecraftinstance", "manifest"); public static boolean isValidInstanceId(String id) { + if (!GameInstanceID.isValid(id)) + return false; + if (FORBIDDEN_INSTANCE_IDS.contains(id)) return false; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java index a8f54902790..30365909d75 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -52,7 +52,7 @@ public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpa this.modpack = modpack; Path run = repository.getRunDirectory(this.instanceId); - Path json = repository.getModpackConfiguration(this.instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId); if (repository.hasInstance(this.instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists"); @@ -73,7 +73,7 @@ public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpa } catch (JsonParseException | IOException ignore) { } dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/minecraft"), it -> !"pack.json".equals(it), config)); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(this.instanceId)).withStage("hmcl.modpack")); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/minecraft"), modpack, HMCLModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(this.instanceId)).withStage("hmcl.modpack")); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java index aace4d4ea33..5788333781b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java @@ -18,10 +18,7 @@ package org.jackhuang.hmcl.ui.download; import javafx.scene.Node; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameRepository; -import org.jackhuang.hmcl.game.ManuallyCreatedModpackException; -import org.jackhuang.hmcl.game.ModpackHelper; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackCompletionException; @@ -109,9 +106,9 @@ private Task finishModpackInstallingAsync(SettingsMap settings) { } try { if (serverModpackManifest != null) { - return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId))); + return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId))); } else { - return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getModpackConfiguration(instanceId))); + return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId))); } } catch (UnsupportedModpackException | ManuallyCreatedModpackException e) { Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR); 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 d81562675f0..a390bf49a25 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -591,10 +591,6 @@ public Task saveAsync(GameInstanceManifest instanceManifes }); } - public Path getModpackConfiguration(GameInstanceID instanceId) { - return getInstanceRoot(instanceId).resolve("modpack.json"); - } - @Override public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { return getSnapshot().resolve(manifest); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java index c838ee09ad4..ed84f363c76 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryLayout.java @@ -70,6 +70,10 @@ public Path getInstanceJarFile(GameInstanceID instanceId) { return getInstanceRoot(instanceId).resolve(instanceId.id() + ".jar"); } + public Path getModpackConfigurationFile(GameInstanceID instanceId) { + return getInstanceRoot(instanceId).resolve("modpack.cfg"); + } + /// {@inheritDoc} /// /// Official layout path: `libraries/` below the base directory. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java index 39b7ae1c270..c289480aa5b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java @@ -28,13 +28,19 @@ import java.io.IOException; +/// @author Glavo @NotNullByDefault @JsonAdapter(GameInstanceID.Adapter.class) @JsonSerializable public record GameInstanceID(String id) implements Comparable { + + public static boolean isValid(String id) { + return !id.isBlank() && !id.contains("/") && !id.contains("\\"); + } + public GameInstanceID { - if (id.isBlank()) { - throw new IllegalArgumentException("Game instance id cannot be empty"); + if (!isValid(id)) { + throw new IllegalArgumentException("Invalid game instance id: " + id); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java index 6df992bef96..c45e8f6a900 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -19,7 +19,6 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.platform.Platform; import org.jetbrains.annotations.NotNullByDefault; import java.nio.file.Files; @@ -142,15 +141,6 @@ default Path getInstanceRoot(GameInstanceID instanceId) { /// @return the run directory Path getRunDirectory(GameInstanceID instanceId); - /// Returns the directory used for extracted native libraries of an instance and platform. - /// - /// @param instanceId the instance id - /// @param platform the target platform - /// @return the native library directory - default Path getNativeDirectory(GameInstanceID instanceId, Platform platform) { - return getInstanceRoot(instanceId).resolve("natives-" + platform); - } - /// Returns the mods directory for an instance. /// /// @param instanceId the instance id 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 a069024112b..1c9a6116a1d 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 @@ -79,7 +79,7 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile this.run = repository.getRunDirectory(instanceId); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); @@ -116,7 +116,7 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile } this.config = config; dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList(manifest.overrides()), any -> true, config).withStage("hmcl.modpack")); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList(manifest.overrides()), manifest, CurseModpackProvider.INSTANCE, manifest.name(), manifest.version(), repository.getModpackConfiguration(instanceId)).withStage("hmcl.modpack")); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList(manifest.overrides()), manifest, CurseModpackProvider.INSTANCE, manifest.name(), manifest.version(), repository.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); URI iconUri = NetworkUtils.toURIOrNull(iconUrl); if (iconUri != null) { 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 86fcaa0b4ba..f594b7270eb 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 @@ -61,7 +61,7 @@ public McbbsModpackLocalInstallTask(DefaultDependencyManager dependencyManager, this.repository = dependencyManager.getGameRepository(); Path run = repository.getRunDirectory(instanceId); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); this.update = repository.hasInstance(instanceId); @@ -89,7 +89,7 @@ public McbbsModpackLocalInstallTask(DefaultDependencyManager dependencyManager, } catch (JsonParseException | IOException ignore) { } dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/overrides"), any -> true, config).withStage("hmcl.modpack")); - instanceTask = new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/overrides"), manifest, McbbsModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(instanceId)); + instanceTask = new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/overrides"), manifest, McbbsModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(instanceId)); dependents.add(instanceTask.withStage("hmcl.modpack")); } 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 2e77f682498..c8975025883 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 @@ -64,7 +64,7 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF this.repository = dependencyManager.getGameRepository(); this.run = repository.getRunDirectory(instanceId); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); @@ -116,7 +116,7 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF this.config = config; List subDirectories = Arrays.asList("/client-overrides", "/overrides"); dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), subDirectories, any -> true, config).withStage("hmcl.modpack")); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), subDirectories, manifest, ModrinthModpackProvider.INSTANCE, manifest.getName(), manifest.getVersionId(), repository.getModpackConfiguration(instanceId)).withStage("hmcl.modpack")); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), subDirectories, manifest, ModrinthModpackProvider.INSTANCE, manifest.getName(), manifest.getVersionId(), repository.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); URI iconUri = NetworkUtils.toURIOrNull(iconUrl); if (iconUri != null) { 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 5acda8e2609..5167f53def4 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 @@ -90,7 +90,7 @@ public MultiMCModpackInstallTask(DefaultDependencyManager dependencyManager, Pat this.dependencyManager = dependencyManager; this.repository = dependencyManager.getGameRepository(); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); @@ -110,7 +110,7 @@ public void preExecute() throws Exception { // Stage #0: General Setup { Path run = repository.getRunDirectory(instanceId); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); ModpackConfiguration config = null; try { @@ -130,7 +130,7 @@ public void preExecute() throws Exception { // TODO: Optimize unbearably slow ModpackInstallTask dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList(mcDirectory), any -> true, config).withStage("hmcl.modpack")); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList(mcDirectory), manifest, MultiMCModpackProvider.INSTANCE, manifest.getName(), null, repository.getModpackConfiguration(instanceId)).withStage("hmcl.modpack")); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList(mcDirectory), manifest, MultiMCModpackProvider.INSTANCE, manifest.getName(), null, repository.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); } // Stage #1: Load all related Json-Patch from meta maven or local mod pack. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java index f038c3064c2..10ed8cb7923 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 @@ -54,7 +54,7 @@ public ServerModpackLocalInstallTask(DefaultDependencyManager dependencyManager, this.repository = dependencyManager.getGameRepository(); Path run = repository.getRunDirectory(instanceId); - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); @@ -80,7 +80,7 @@ public ServerModpackLocalInstallTask(DefaultDependencyManager dependencyManager, } catch (JsonParseException | IOException ignore) { } dependents.add(new ModpackInstallTask<>(zipFile, run, modpack.getEncoding(), Collections.singletonList("/overrides"), any -> true, config).withStage("hmcl.modpack")); - dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/overrides"), manifest, ServerModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getModpackConfiguration(instanceId)).withStage("hmcl.modpack")); + dependents.add(new MinecraftInstanceTask<>(zipFile, modpack.getEncoding(), Collections.singletonList("/overrides"), manifest, ServerModpackProvider.INSTANCE, modpack.getName(), modpack.getVersion(), repository.getLayout().getModpackConfigurationFile(instanceId)).withStage("hmcl.modpack")); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java index 9ceb7229260..c56831da127 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java @@ -48,7 +48,7 @@ public ServerModpackRemoteInstallTask(DefaultDependencyManager dependencyManager this.repository = dependencyManager.getGameRepository(); this.manifest = manifest; - Path json = repository.getModpackConfiguration(instanceId); + Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); From 6935c2c67494297fa16371168e9be6bd4b3cb79c Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 20:07:04 +0800 Subject: [PATCH 070/199] refactor(HMCLGameInstance, HMCLGameRepository, LauncherHelper): consolidate launch options handling and improve modpack configuration integration --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 101 ++++++++++++++++-- .../hmcl/game/HMCLGameRepository.java | 83 -------------- .../jackhuang/hmcl/game/LauncherHelper.java | 11 +- 3 files changed, 95 insertions(+), 100 deletions(-) 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 bce13287a05..303b6edbc2f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -21,16 +21,12 @@ import com.google.gson.JsonParseException; import com.google.gson.reflect.TypeToken; import javafx.scene.image.Image; +import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.modpack.ModpackConfiguration; -import org.jackhuang.hmcl.setting.DefaultIsolationType; -import org.jackhuang.hmcl.setting.GameSettings; -import org.jackhuang.hmcl.setting.GameInstanceIconType; -import org.jackhuang.hmcl.setting.GameSettingsPresetID; -import org.jackhuang.hmcl.setting.LauncherSettings; -import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator; -import org.jackhuang.hmcl.setting.SettingFileUtils; -import org.jackhuang.hmcl.setting.SettingsManager; +import org.jackhuang.hmcl.modpack.ModpackProvider; +import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.ui.FXUtils; import org.jackhuang.hmcl.util.FileSaver; import org.jackhuang.hmcl.util.Lang; @@ -38,6 +34,7 @@ import org.jackhuang.hmcl.util.gson.JsonSchema; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.platform.SystemInfo; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNullByDefault; @@ -47,9 +44,12 @@ import java.nio.file.Files; import java.nio.file.InvalidPathException; import java.nio.file.Path; +import java.util.List; import java.util.Locale; import java.util.Objects; +import java.util.stream.Collectors; +import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// HMCL-specific game instance that owns instance-local settings and run-directory policy. @@ -180,7 +180,7 @@ public boolean isTreatingAsModpack() { /// @return the `modpack.cfg` path in the instance root @Override public Path getModpackConfigurationFile() { - return getInstanceRoot().resolve("modpack.cfg"); + return getLayout().getModpackConfigurationFile(getId()); } /// Returns whether this instance has an HMCL modpack configuration file. @@ -602,6 +602,89 @@ private Path getGameSettingsFile() { return getLayout().getInstanceGameSettingsFile(id); } + public LaunchOptions.Builder getLaunchOptions(JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { + GameSettings.Effective vs = getEffectiveSettings(); + boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty); + boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty); + GameVersionNumber gameVersionNumber = getVersion(); + + @Nullable Integer maxMemory; + if (autoMemory) { + maxMemory = noJVMOptions + ? null + : Math.toIntExact(HMCLGameRepository.getAutoAllocatedMemory(SystemInfo.getPhysicalMemoryStatus().available()) / 1024L / 1024L); + } else { + maxMemory = vs.getMaxMemory(); + } + + LaunchOptions.Builder builder = new LaunchOptions.Builder() + .setInstanceId(getId()) + .setGameDir(gameDir) + .setJava(javaVersion) + .setVersionType(Metadata.TITLE) + .setVersionName(getId().id()) + .setProfileName(Metadata.TITLE) + .setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty))) + .setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty))) + .setMaxMemory(maxMemory) + .setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty)) + .setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty))) + .setEnvironmentVariables( + Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty)) + .stream() + .map(it -> { + int idx = it.indexOf('='); + return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, ""); + }) + .collect(Collectors.toList()) + ) + ) + .setWidth(vs.getWidth()) + .setHeight(vs.getHeight()) + .setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN) + .setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty)) + .setProxyOption(HMCLGameRepository.getProxyOption()) + .setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty)) + .setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty)) + .setNoGeneratedJVMArgs(noJVMOptions) + .setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)) + .setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty)) + .setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty)) + .setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty)) + .setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty)) + .setRenderer(vs.getRenderer(gameVersionNumber)) + .setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty)) + .setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty)) + .setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty)) + .setUseNativeGLFW(vs.getInheritable(GameSettings::useNativeGLFWProperty)) + .setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty)) + .setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon()) + .setJavaAgents(javaAgents) + .setJavaArguments(javaArguments); + + QuickPlayOption quickPlayOption = vs.getQuickPlayOption(); + if (quickPlayOption != null) { + builder.setQuickPlayOption(quickPlayOption); + } + + Path json = getModpackConfigurationFile(); + if (Files.exists(json)) { + try { + String jsonText = Files.readString(json); + ModpackConfiguration modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class); + ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType()); + if (provider != null) provider.injectLaunchOptions(jsonText, builder); + } catch (IOException | JsonParseException e) { + LOG.warning("Failed to parse modpack configuration file " + json, e); + } + } + + if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx"))) + builder.setMaxMemory(null); + + return builder; + } + /// Loads a new-format instance game settings file. private static LoadResult loadGameSettingsFile(Path file) { if (!Files.exists(file)) { 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 f87da7d1611..70fd9656e98 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -392,89 +392,6 @@ public void applyDefaultIsolationSettingForNewInstance(GameInstanceID instanceId } } - public LaunchOptions.Builder getLaunchOptions(GameInstanceID instanceId, JavaRuntime javaVersion, Path gameDir, List javaAgents, List javaArguments, boolean makeLaunchScript) { - GameSettings.Effective vs = getEffectiveGameSettings(instanceId); - boolean noJVMOptions = vs.getInheritable(GameSettings::noJVMOptionsProperty); - boolean autoMemory = vs.getInheritable(GameSettings::autoMemoryProperty); - GameVersionNumber gameVersionNumber = GameVersionNumber.asGameVersion(getGameVersion(instanceId)); - - @Nullable Integer maxMemory; - if (autoMemory) { - maxMemory = noJVMOptions - ? null - : Math.toIntExact(getAutoAllocatedMemory(SystemInfo.getPhysicalMemoryStatus().available()) / 1024L / 1024L); - } else { - maxMemory = vs.getMaxMemory(); - } - - LaunchOptions.Builder builder = new LaunchOptions.Builder() - .setInstanceId(instanceId) - .setGameDir(gameDir) - .setJava(javaVersion) - .setVersionType(Metadata.TITLE) - .setVersionName(instanceId.id()) - .setProfileName(Metadata.TITLE) - .setGameArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::gameArgumentsProperty))) - .setOverrideJavaArguments(StringUtils.tokenize(vs.getInheritable(GameSettings::jvmOptionsProperty))) - .setMaxMemory(maxMemory) - .setMinMemory(vs.getInheritable(GameSettings::minMemoryProperty)) - .setMetaspace(Lang.toIntOrNull(vs.getInheritable(GameSettings::permSizeProperty))) - .setEnvironmentVariables( - Lang.mapOf(StringUtils.tokenize(vs.getInheritable(GameSettings::environmentVariablesProperty)) - .stream() - .map(it -> { - int idx = it.indexOf('='); - return idx >= 0 ? pair(it.substring(0, idx), it.substring(idx + 1)) : pair(it, ""); - }) - .collect(Collectors.toList()) - ) - ) - .setWidth(vs.getWidth()) - .setHeight(vs.getHeight()) - .setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN) - .setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty)) - .setProxyOption(getProxyOption()) - .setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty)) - .setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty)) - .setNoGeneratedJVMArgs(noJVMOptions) - .setNoGeneratedOptimizingJVMArgs(vs.getInheritable(GameSettings::noOptimizingJVMOptionsProperty)) - .setUseCustomNatives(vs.getInheritable(GameSettings::useCustomNativesProperty)) - .setNativesDir(vs.getInheritable(GameSettings::nativesDirectoryProperty)) - .setProcessPriority(vs.getInheritable(GameSettings::processPriorityProperty)) - .setGraphicsBackend(vs.getInheritable(GameSettings::graphicsBackendProperty)) - .setRenderer(vs.getRenderer(gameVersionNumber)) - .setEnableDebugLogOutput(vs.getInheritable(GameSettings::enableDebugLogOutputProperty)) - .setAllowAutoAgent(vs.getInheritable(GameSettings::allowAutoAgentProperty)) - .setDisableAutoGameOptions(vs.getInheritable(GameSettings::disableAutoGameOptionsProperty)) - .setUseNativeGLFW(vs.getInheritable(GameSettings::useNativeGLFWProperty)) - .setUseNativeOpenAL(vs.getInheritable(GameSettings::useNativeOpenALProperty)) - .setDaemon(!makeLaunchScript && vs.getInheritable(GameSettings::launcherVisibilityProperty).isDaemon()) - .setJavaAgents(javaAgents) - .setJavaArguments(javaArguments); - - QuickPlayOption quickPlayOption = vs.getQuickPlayOption(); - if (quickPlayOption != null) { - builder.setQuickPlayOption(quickPlayOption); - } - - Path json = getLayout().getModpackConfigurationFile(instanceId); - if (Files.exists(json)) { - try { - String jsonText = Files.readString(json); - ModpackConfiguration modpackConfiguration = JsonUtils.GSON.fromJson(jsonText, ModpackConfiguration.class); - ModpackProvider provider = ModpackHelper.getProviderByType(modpackConfiguration.getType()); - if (provider != null) provider.injectLaunchOptions(jsonText, builder); - } catch (IOException | JsonParseException e) { - LOG.warning("Failed to parse modpack configuration file " + json, e); - } - } - - if (autoMemory && builder.getJavaArguments().stream().anyMatch(it -> it.startsWith("-Xmx"))) - builder.setMaxMemory(null); - - return builder; - } - /// Marks the instance as a modpack for run-directory resolution during installation. /// /// @param instanceId the instance id 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 d7af8f09900..3fcdb8e8626 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -110,10 +110,6 @@ private HMCLGameRepository repository() { return gameInstance.getRepository(); } - private GameInstanceID instanceId() { - return gameInstance.getId(); - } - private final TaskExecutorDialogPane launchingStepsPane = new TaskExecutorDialogPane(TaskCancellationAction.NORMAL); public Account getAccount() { @@ -144,7 +140,7 @@ public void setDisableOfflineSkin() { public void launch() { FXUtils.checkFxUserThread(); - LOG.info("Launching game version: " + instanceId()); + LOG.info("Launching game instance: " + gameInstance.getId()); Controllers.dialog(launchingStepsPane); launch0(); @@ -161,7 +157,6 @@ private void launch0() { PROCESSES.removeIf(it -> it.get() == null); HMCLGameRepository repository = repository(); - GameInstanceID selectedInstanceId = instanceId(); DefaultDependencyManager dependencyManager = repository.getDependency(); AtomicReference version = new AtomicReference<>( LaunchManifestPreparation.prepare( @@ -256,8 +251,8 @@ private void launch0() { }) .thenComposeAsync(() -> logIn(account).withStage("launch.state.logging_in")) .thenComposeAsync(authInfo -> Task.supplyAsync(() -> { - LaunchOptions.Builder launchOptionsBuilder = repository.getLaunchOptions( - selectedInstanceId, javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null); + LaunchOptions.Builder launchOptionsBuilder = gameInstance.getLaunchOptions( + javaVersionRef.get(), repository.getBaseDirectory(), javaAgents, javaArguments, scriptFile != null); if (disableOfflineSkin) { launchOptionsBuilder.setDaemon(false); } From e8c6ce55f0259f3aee68137a06c41ecc90238b34 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 20:08:03 +0800 Subject: [PATCH 071/199] refactor(HMCLGameInstance): simplify running directory retrieval by removing unnecessary null checks --- .../src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java | 2 -- 1 file changed, 2 deletions(-) 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 303b6edbc2f..c810082fe60 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -237,12 +237,10 @@ private String selectedRunningDirectory( return ""; } - //noinspection DataFlowIssue return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), ""); } GameSettings.Preset parent = getRepository().getParentGameSettings(localSetting); - //noinspection DataFlowIssue return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), ""); } From cd8fd5070afa4ea3684e3e60a855d71782c1dd23 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 20:13:20 +0800 Subject: [PATCH 072/199] refactor(HMCLGameInstance, HMCLGameRepository): move proxy option retrieval to HMCLGameInstance for better encapsulation --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 34 ++++++++++++++++++- .../hmcl/game/HMCLGameRepository.java | 29 ---------------- 2 files changed, 33 insertions(+), 30 deletions(-) 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 c810082fe60..32f156aba9f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -49,6 +49,7 @@ import java.util.Objects; import java.util.stream.Collectors; +import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -641,7 +642,7 @@ public LaunchOptions.Builder getLaunchOptions(JavaRuntime javaVersion, Path game .setHeight(vs.getHeight()) .setFullscreen(vs.getInheritable(GameSettings::windowTypeProperty) == GameWindowType.FULLSCREEN) .setWrapper(vs.getInheritable(GameSettings::commandWrapperProperty)) - .setProxyOption(HMCLGameRepository.getProxyOption()) + .setProxyOption(getProxyOption()) .setPreLaunchCommand(vs.getInheritable(GameSettings::preLaunchCommandProperty)) .setPostExitCommand(vs.getInheritable(GameSettings::postExitCommandProperty)) .setNoGeneratedJVMArgs(noJVMOptions) @@ -683,6 +684,37 @@ public LaunchOptions.Builder getLaunchOptions(JavaRuntime javaVersion, Path game return builder; } + private static ProxyOption getProxyOption() { + return switch (settings().proxyTypeProperty().get()) { + case SYSTEM -> ProxyOption.Default.INSTANCE; + case DIRECT -> ProxyOption.Direct.INSTANCE; + case HTTP, SOCKS -> { + String proxyHost = settings().proxyHostProperty().get(); + int proxyPort = settings().proxyPortProperty().get(); + + if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) { + yield ProxyOption.Default.INSTANCE; + } + + String proxyUser = settings().proxyUserProperty().get(); + String proxyPass = settings().proxyPasswordProperty().get(); + + if (StringUtils.isBlank(proxyUser)) { + proxyUser = null; + proxyPass = null; + } else if (proxyPass == null) { + proxyPass = ""; + } + + if (settings().proxyTypeProperty().get() == ProxyType.HTTP) { + yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass); + } else { + yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass); + } + } + }; + } + /// Loads a new-format instance game settings file. private static LoadResult loadGameSettingsFile(Path file) { if (!Files.exists(file)) { 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 70fd9656e98..ac002408de3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -468,34 +468,5 @@ public static long getAutoAllocatedMemory(long available) { return suggested; } - public static ProxyOption getProxyOption() { - return switch (settings().proxyTypeProperty().get()) { - case SYSTEM -> ProxyOption.Default.INSTANCE; - case DIRECT -> ProxyOption.Direct.INSTANCE; - case HTTP, SOCKS -> { - String proxyHost = settings().proxyHostProperty().get(); - int proxyPort = settings().proxyPortProperty().get(); - - if (StringUtils.isBlank(proxyHost) || proxyPort < 0 || proxyPort > 0xFFFF) { - yield ProxyOption.Default.INSTANCE; - } - - String proxyUser = settings().proxyUserProperty().get(); - String proxyPass = settings().proxyPasswordProperty().get(); - - if (StringUtils.isBlank(proxyUser)) { - proxyUser = null; - proxyPass = null; - } else if (proxyPass == null) { - proxyPass = ""; - } - if (settings().proxyTypeProperty().get() == ProxyType.HTTP) { - yield new ProxyOption.Http(proxyHost, proxyPort, proxyUser, proxyPass); - } else { - yield new ProxyOption.Socks(proxyHost, proxyPort, proxyUser, proxyPass); - } - } - }; - } } From 8ecd2cefb2e6ba758b2dc2f766d44e44daf1976d Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 20:14:50 +0800 Subject: [PATCH 073/199] refactor(HMCLGameInstance): remove redundant initSettings method and simplify getIconFile return type --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) 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 32f156aba9f..4749379bad7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -385,14 +385,6 @@ public void saveSettingsSync() throws IOException { FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); } - /// Initializes this instance with the given settings object. - /// - /// @param setting the settings to install - /// @return the installed settings - public GameSettings.Instance initSettings(GameSettings.Instance setting) { - return initSettings(setting, true); - } - /// Initializes this instance with the given settings object. /// /// @param setting the settings to install @@ -431,14 +423,14 @@ public GameSettings.Instance copySettings() { /// Returns the first custom icon file found in this instance's root directory. /// /// @return the icon file, or empty when no supported icon file exists - public java.util.Optional getIconFile() { + public @Nullable Path getIconFile() { for (String extension : FXUtils.IMAGE_EXTENSIONS) { Path file = getInstanceRoot().resolve("icon." + extension); if (Files.exists(file)) { - return java.util.Optional.of(file); + return file; } } - return java.util.Optional.empty(); + return null; } /// Replaces this instance's custom icon file. @@ -491,10 +483,10 @@ public Image getIconImage() { return iconType.getIcon(); } - java.util.Optional iconFile = getIconFile(); - if (iconFile.isPresent()) { + @Nullable Path iconFile = getIconFile(); + if (iconFile != null) { try { - return FXUtils.loadImage(iconFile.get(), 64, 64, true, true); + return FXUtils.loadImage(iconFile, 64, 64, true, true); } catch (Exception e) { LOG.warning("Failed to load instance icon for " + id, e); } From bda387ec01c4692884bcd246a0c4026b98e6d311 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 20:36:22 +0800 Subject: [PATCH 074/199] Remove provisional instances and install-time markAsModpack flags Assisted-by: grok-build:grok-4.5 --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 106 +------ .../hmcl/game/HMCLGameRepository.java | 260 ++++++++++++------ .../jackhuang/hmcl/game/ModpackHelper.java | 16 +- .../hmcl/setting/GameDirectoriesTest.java | 4 +- .../hmcl/game/DefaultGameInstance.java | 11 - .../hmcl/game/DefaultGameRepository.java | 7 +- .../game/DefaultGameRepositorySnapshot.java | 33 +-- .../hmcl/game/GameRepositorySnapshot.java | 3 +- 8 files changed, 202 insertions(+), 238 deletions(-) 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 4749379bad7..51662bfcc14 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -42,7 +42,6 @@ import java.io.IOException; import java.nio.file.Files; -import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.util.List; import java.util.Locale; @@ -57,13 +56,6 @@ @NotNullByDefault public class HMCLGameInstance extends DefaultGameInstance { - /// Whether this instance is only a provisional placeholder in the current snapshot. - private final boolean provisional; - - /// Whether install-time code currently treats this instance as a modpack for run-directory - /// resolution, before [#isModpack()] becomes true. - private boolean treatingAsModpack; - /// Whether the instance-local game settings file has already been inspected. private boolean gameSettingsLoaded; @@ -79,7 +71,7 @@ public class HMCLGameInstance extends DefaultGameInstance { /// @param id the instance id /// @param manifest the stored instance manifest protected HMCLGameInstance(DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest) { - this(snapshot, id, manifest, null, false); + this(snapshot, id, manifest, (Path) null); } /// Creates a registered instance with an optional non-conventional manifest path. @@ -93,41 +85,19 @@ protected HMCLGameInstance( GameInstanceID id, GameInstanceManifest manifest, @Nullable Path manifestFile) { - this(snapshot, id, manifest, manifestFile, false); - } - - /// Creates a provisional instance used before a real manifest is indexed. - /// - /// @param snapshot the repository snapshot that owns this instance - /// @param id the instance id - /// @return a provisional instance with an empty placeholder manifest - static HMCLGameInstance provisional(DefaultGameRepositorySnapshot snapshot, GameInstanceID id) { - return new HMCLGameInstance(snapshot, id, new GameInstanceManifest(id), null, true); - } - - private HMCLGameInstance( - DefaultGameRepositorySnapshot snapshot, - GameInstanceID id, - GameInstanceManifest manifest, - @Nullable Path manifestFile, - boolean provisional) { super(snapshot, id, manifest, manifestFile); - this.provisional = provisional; } /// Creates an instance that shares mutable instance-local state with another instance. /// - /// Used when the repository clones a snapshot or promotes a provisional instance so that - /// settings and install-time flags remain available on the new wrapper. + /// Used when the repository clones a snapshot so that settings remain available on the new + /// wrapper. private HMCLGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, GameInstanceManifest manifest, - boolean provisional, HMCLGameInstance shareState) { super(snapshot, id, manifest, shareState); - this.provisional = provisional; - this.treatingAsModpack = shareState.treatingAsModpack; this.gameSettingsLoaded = shareState.gameSettingsLoaded; this.gameSettingsReadOnly = shareState.gameSettingsReadOnly; this.gameSettings = shareState.gameSettings; @@ -135,18 +105,12 @@ private HMCLGameInstance( @Override protected HMCLGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) { - return new HMCLGameInstance(newSnapshot, id, manifest, provisional, this); + return new HMCLGameInstance(newSnapshot, id, manifest, this); } @Override protected HMCLGameInstance withManifest(DefaultGameRepositorySnapshot newSnapshot, GameInstanceManifest manifest) { - // A real stored manifest promotes a provisional placeholder to a registered instance. - return new HMCLGameInstance(newSnapshot, id, manifest, false, this); - } - - @Override - public boolean isProvisional() { - return provisional; + return new HMCLGameInstance(newSnapshot, id, manifest, this); } @Override @@ -159,23 +123,6 @@ public HMCLGameRepositoryLayout getLayout() { return (HMCLGameRepositoryLayout) super.getLayout(); } - /// Marks this instance as a modpack for run-directory resolution during installation. - public void markAsModpack() { - treatingAsModpack = true; - } - - /// Clears the install-time modpack mark. - public void unmarkAsModpack() { - treatingAsModpack = false; - } - - /// Returns whether install-time code currently treats this instance as a modpack. - /// - /// @return whether [#markAsModpack()] is in effect - public boolean isTreatingAsModpack() { - return treatingAsModpack; - } - /// Returns the HMCL modpack configuration file for this instance. /// /// @return the `modpack.cfg` path in the instance root @@ -209,40 +156,7 @@ public boolean isModpack() { @Override public Path getRunDirectory() { - if (treatingAsModpack || isModpack()) { - return getInstanceRoot(); - } - - @Nullable GameSettings.Instance localSetting = getSettings(); - boolean useInstanceRunningDirectory = - localSetting != null - && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY); - - String runningDirectory = selectedRunningDirectory(localSetting, useInstanceRunningDirectory); - if (StringUtils.isBlank(runningDirectory)) { - return useInstanceRunningDirectory ? getInstanceRoot() : getLayout().getBaseDirectory(); - } - - try { - return Path.of(runningDirectory); - } catch (InvalidPathException ignored) { - return getInstanceRoot(); - } - } - - private String selectedRunningDirectory( - @Nullable GameSettings.Instance localSetting, - boolean useInstanceRunningDirectory) { - if (useInstanceRunningDirectory) { - if (localSetting == null) { - return ""; - } - - return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), ""); - } - - GameSettings.Preset parent = getRepository().getParentGameSettings(localSetting); - return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), ""); + return getRepository().resolveRunDirectory(getId(), isModpack(), getSettings()); } /// Returns the loaded instance-local game settings, loading them on first access. @@ -272,14 +186,8 @@ public GameSettings.Effective getEffectiveSettings() { return GameSettings.resolve(getRepository().getParentGameSettings(setting), setting); } - /// Applies the selected parent preset's default isolation policy to this registered instance. - /// - /// Provisional instances are unchanged because their final manifest has not been indexed yet. + /// Applies the selected parent preset's default isolation policy to this instance. public void applyDefaultIsolationSetting() { - if (isProvisional()) { - return; - } - @Nullable GameSettings.Instance instanceSetting = getSettings(); GameSettings.Preset preset = getRepository().getParentGameSettings(instanceSetting); DefaultIsolationType type = Lang.requireNonNullElse( 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 ac002408de3..070e81cd747 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -17,36 +17,29 @@ */ package org.jackhuang.hmcl.game; -import com.google.gson.JsonParseException; import javafx.beans.binding.Bindings; import javafx.beans.binding.ObjectBinding; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.ReadOnlyObjectWrapper; -import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.event.EventManager; -import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; -import org.jackhuang.hmcl.modpack.ModpackConfiguration; -import org.jackhuang.hmcl.modpack.ModpackProvider; import org.jackhuang.hmcl.setting.SettingsManager; import org.jackhuang.hmcl.setting.DefaultIsolationType; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameSettings; -import org.jackhuang.hmcl.setting.GameWindowType; import org.jackhuang.hmcl.setting.GameDirectory; -import org.jackhuang.hmcl.setting.ProxyType; +import org.jackhuang.hmcl.setting.LauncherSettings; +import org.jackhuang.hmcl.setting.LegacyGameSettingsMigrator; import org.jackhuang.hmcl.setting.GameSettingsPresetID; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.OperatingSystem; -import org.jackhuang.hmcl.util.platform.SystemInfo; -import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -56,11 +49,9 @@ import java.nio.file.Path; import java.time.Instant; import java.util.*; -import java.util.stream.Collectors; import java.util.stream.Stream; import static org.jackhuang.hmcl.setting.SettingsManager.settings; -import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// HMCL game repository implementation backed by a GameDirectory and per-instance game settings. @@ -135,36 +126,12 @@ public HMCLGameInstance getInstance(GameInstanceID id) throws NoSuchGameInstance /// Returns the indexed instance for the given id, or `null` when it is not loaded. /// - /// Provisional placeholders are excluded. - /// /// @param id the instance id /// @return the instance, or `null` when absent public @Nullable HMCLGameInstance findInstance(GameInstanceID id) { return (HMCLGameInstance) getSnapshot().findInstance(id); } - /// Returns the instance that owns local state for the given id. - /// - /// When the id is already present in the current snapshot (including provisional placeholders), - /// that instance is returned. Otherwise a provisional [HMCLGameInstance] is created and published - /// in a new snapshot until it is promoted by a real manifest or the snapshot is replaced by - /// refresh. - /// - /// @param instanceId the instance id - /// @return the instance used to manage settings and install-time state for the id - private HMCLGameInstance resolveInstance(GameInstanceID instanceId) { - DefaultGameInstance existing = findSnapshotInstance(instanceId); - if (existing != null) { - return (HMCLGameInstance) existing; - } - - HMCLGameRepositorySnapshot newSnapshot = getSnapshot().clone(); - HMCLGameInstance provisional = HMCLGameInstance.provisional(newSnapshot, instanceId); - newSnapshot.put(provisional); - publishSnapshot(newSnapshot); - return provisional; - } - /// Returns the persistent game directory for this repository. public GameDirectory getGameDirectory() { return gameDirectory; @@ -239,7 +206,125 @@ public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) @Override public Path getRunDirectory(GameInstanceID instanceId) { - return resolveInstance(instanceId).getRunDirectory(); + HMCLGameInstance instance = findInstance(instanceId); + if (instance != null) { + return instance.getRunDirectory(); + } + boolean modpack = Files.exists(getLayout().getModpackConfigurationFile(instanceId)); + return resolveRunDirectory(instanceId, modpack, peekInstanceGameSettings(instanceId)); + } + + /// Resolves the run directory for an instance id from modpack state and local settings. + /// + /// @param instanceId the instance id + /// @param modpack whether the instance is an HMCL modpack (`modpack.cfg` present) + /// @param localSetting the instance-local settings, or `null` when absent + /// @return the run directory + Path resolveRunDirectory( + GameInstanceID instanceId, + boolean modpack, + GameSettings.@Nullable Instance localSetting) { + Path instanceRoot = getLayout().getInstanceRoot(instanceId); + if (modpack) { + return instanceRoot; + } + + boolean useInstanceRunningDirectory = + localSetting != null + && localSetting.getOverrideProperties().contains(GameSettings.PROPERTY_RUNNING_DIRECTORY); + + String runningDirectory = selectedRunningDirectory(localSetting, useInstanceRunningDirectory); + if (StringUtils.isBlank(runningDirectory)) { + return useInstanceRunningDirectory ? instanceRoot : getBaseDirectory(); + } + + try { + return Path.of(runningDirectory); + } catch (Exception ignored) { + return instanceRoot; + } + } + + private String selectedRunningDirectory( + GameSettings.@Nullable Instance localSetting, + boolean useInstanceRunningDirectory) { + if (useInstanceRunningDirectory) { + if (localSetting == null) { + return ""; + } + return Objects.requireNonNullElse(localSetting.runningDirectoryProperty().getValue(), ""); + } + + GameSettings.Preset parent = getParentGameSettings(localSetting); + return Objects.requireNonNullElse(parent.runningDirectoryProperty().getValue(), ""); + } + + /// Reads instance-local settings from disk without requiring a registered snapshot member. + /// + /// Used for install-time path resolution and migration before the instance is indexed. Does not + /// publish a snapshot entry. + /// + /// @param instanceId the instance id + /// @return the loaded settings, or `null` when none can be loaded + private GameSettings.@Nullable Instance peekInstanceGameSettings(GameInstanceID instanceId) { + Path file = getLayout().getInstanceGameSettingsFile(instanceId); + if (!Files.isRegularFile(file)) { + return null; + } + try { + return LauncherSettings.SETTINGS_GSON + .fromJson(Files.readString(file), GameSettings.Instance.class); + } catch (Exception e) { + LOG.warning("Failed to peek instance game settings: " + file, e); + return null; + } + } + + /// Writes instance-local settings to disk for an id that may not yet be registered. + /// + /// @param instanceId the instance id + /// @param setting the settings to write + /// @throws IOException if the file cannot be written + private void writeInstanceGameSettings(GameInstanceID instanceId, GameSettings.Instance setting) + throws IOException { + Path file = getLayout().getInstanceGameSettingsFile(instanceId).toAbsolutePath().normalize(); + Files.createDirectories(file.getParent()); + setting.setSchema(GameSettings.Instance.CURRENT_SCHEMA); + FileUtils.saveSafely(file, LauncherSettings.SETTINGS_GSON.toJson(setting)); + } + + /// Ensures the instance uses an isolated running directory under its instance root. + /// + /// When the instance is already registered, settings are updated through + /// [HMCLGameInstance]. Otherwise the isolation flag is written to the instance settings file + /// so install tasks that call [#getRunDirectory(GameInstanceID)] see the isolated path. + /// + /// @param instanceId the instance id + public void ensureIsolatedRunningDirectory(GameInstanceID instanceId) { + HMCLGameInstance instance = findInstance(instanceId); + if (instance != null) { + if (instance.isSettingsReadOnly()) { + return; + } + GameSettings.Instance setting = instance.getSettingsOrCreate(); + if (setting != null + && setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { + instance.saveSettings(); + } + return; + } + + GameSettings.Instance setting = peekInstanceGameSettings(instanceId); + if (setting == null) { + setting = new GameSettings.Instance(); + } + if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { + try { + writeInstanceGameSettings(instanceId, setting); + } catch (IOException e) { + LOG.warning("Failed to write isolated running directory for " + instanceId, e); + } + } } public Stream getDisplayInstances() { @@ -303,47 +388,49 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea Path srcGameDir = getRunDirectory(srcId); - GameSettings.Instance newGameSettings = resolveInstance(srcId).copySettings(); + GameSettings.Instance newGameSettings = getInstance(srcId).copySettings(); newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); newGameSettings.runningDirectoryProperty().setValue(""); - HMCLGameInstance dstInstance = resolveInstance(dstId); - dstInstance.initSettings(newGameSettings, true); - dstInstance.saveSettingsSync(); + writeInstanceGameSettings(dstId, newGameSettings); Path dstGameDir = getRunDirectory(dstId); if (copyOriginalGameDir) FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null)); + + refresh(); } - /// Returns instance-local settings for an instance ID, creating empty settings when the instance - /// is registered and its settings file is absent and writable. + /// Returns instance-local settings for a registered instance ID, creating empty settings when + /// the settings file is absent and writable. /// - /// This ID-based entry point is retained for installation before an instance has entered the - /// registered snapshot. Code that already has an [HMCLGameInstance] should use + /// Code that already has an [HMCLGameInstance] should use /// [HMCLGameInstance#getSettingsOrCreate()] instead. /// - /// @param instanceId the indexed or pending instance ID - /// @return the settings, or `null` when no settings exist and none can be created + /// @param instanceId the registered instance ID + /// @return the settings, or `null` when the instance is not registered or settings are unavailable public @Nullable GameSettings.Instance getInstanceGameSettingsOrCreate(GameInstanceID instanceId) { - HMCLGameInstance instance = resolveInstance(instanceId); - @Nullable GameSettings.Instance setting = instance.getSettings(); - if (setting == null && hasInstance(instanceId)) { - setting = instance.createSettings(); + HMCLGameInstance instance = findInstance(instanceId); + if (instance == null) { + return null; } - return setting; + return instance.getSettingsOrCreate(); } - /// Returns instance-local settings for an indexed or provisional instance ID. + /// Returns instance-local settings for a registered instance ID. /// - /// This ID-based entry point is retained for installation and legacy migration before an - /// instance has entered the registered snapshot. Code that already has an [HMCLGameInstance] - /// should use [HMCLGameInstance#getSettings()] instead. + /// When the instance is not yet indexed, settings are loaded from disk (including lazy legacy + /// migration) without publishing a snapshot entry. Callers that already have an + /// [HMCLGameInstance] should use [HMCLGameInstance#getSettings()] instead. /// - /// @param instanceId the indexed or pending instance ID + /// @param instanceId the instance ID /// @return the settings, or `null` when no local settings exist public @Nullable GameSettings.Instance getInstanceGameSettings(GameInstanceID instanceId) { - return resolveInstance(instanceId).getSettings(); + HMCLGameInstance instance = findInstance(instanceId); + if (instance != null) { + return instance.getSettings(); + } + return loadOrMigrateInstanceGameSettings(instanceId); } /// Returns the explicit parent preset of the instance, falling back to the default preset. @@ -353,16 +440,15 @@ public GameSettings.Preset getParentGameSettings(@Nullable GameSettings.Instance return parentSetting != null ? parentSetting : SettingsManager.getDefaultGameSettingsPresetOrCreate(); } - /// Resolves effective settings for an indexed or provisional instance ID. + /// Resolves effective settings for a registered instance ID. /// - /// This ID-based entry point is retained for launch construction and installation code that has - /// not yet obtained an [HMCLGameInstance]. Instance-oriented callers should use - /// [HMCLGameInstance#getEffectiveSettings()] instead. + /// Instance-oriented callers should use [HMCLGameInstance#getEffectiveSettings()] instead. /// - /// @param instanceId the indexed or pending instance ID + /// @param instanceId the registered instance ID /// @return the effective settings + /// @throws NoSuchGameInstanceException if the instance is not registered public GameSettings.Effective getEffectiveGameSettings(GameInstanceID instanceId) { - return resolveInstance(instanceId).getEffectiveSettings(); + return getInstance(instanceId).getEffectiveSettings(); } /// Returns whether a new instance should use an isolated running directory under the default isolation settings. @@ -377,36 +463,42 @@ 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 + /// [#getRunDirectory(GameInstanceID)] returns the instance root without requiring a snapshot + /// member. public void applyDefaultIsolationSettingForNewInstance(GameInstanceID instanceId, boolean modded) { - HMCLGameInstance instance = resolveInstance(instanceId); - if (!shouldIsolateNewInstance(modded) || instance.isSettingsReadOnly()) { + if (!shouldIsolateNewInstance(modded)) { return; } + ensureIsolatedRunningDirectory(instanceId); + } - GameSettings.Instance setting = instance.getSettings(); - if (setting == null) { - setting = instance.initSettings(new GameSettings.Instance(), true); + /// Loads settings from disk for an unregistered id, running legacy migration when needed. + private GameSettings.@Nullable Instance loadOrMigrateInstanceGameSettings(GameInstanceID instanceId) { + Path file = getLayout().getInstanceGameSettingsFile(instanceId); + if (Files.isRegularFile(file)) { + return peekInstanceGameSettings(instanceId); } - if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { - instance.saveSettings(); + + @Nullable GameSettingsPresetID legacyParent = getGameDirectory().getLegacyGameSettings(); + if (SettingsManager.getGameSettings(legacyParent) == null) { + legacyParent = null; } - } - /// Marks the instance as a modpack for run-directory resolution during installation. - /// - /// @param instanceId the instance id - public void markInstanceAsModpack(GameInstanceID instanceId) { - resolveInstance(instanceId).markAsModpack(); - } + LegacyGameSettingsMigrator.InstanceMigrationResult migrationResult = + LegacyGameSettingsMigrator.migrateInstanceGameSettings(this, instanceId, legacyParent); + if (migrationResult == null) { + return null; + } - /// Clears the install-time modpack mark for the instance. - /// - /// @param instanceId the instance id - public void undoMark(GameInstanceID instanceId) { - DefaultGameInstance existing = findSnapshotInstance(instanceId); - if (existing != null) { - ((HMCLGameInstance) existing).unmarkAsModpack(); + try { + writeInstanceGameSettings(instanceId, migrationResult.setting()); + migrationResult.saveReceipt(); + } catch (IOException e) { + LOG.warning("Failed to save migrated instance game settings for " + instanceId, e); } + return migrationResult.setting(); } // These instance ids are forbidden because they may conflict with modpack configuration filenames 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 e2fad7f1409..0ba24ade9d4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java @@ -157,15 +157,11 @@ public static ModpackConfiguration readModpackConfiguration(Path file) throws } public static Task getInstallTask(HMCLGameRepository repository, ServerModpackManifest manifest, GameInstanceID instanceId, Modpack modpack) { - repository.markInstanceAsModpack(instanceId); + repository.ensureIsolatedRunningDirectory(instanceId); ExceptionalRunnable success = () -> { repository.refresh(); - GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId); - repository.undoMark(instanceId); - if (setting != null) { - setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); - } + repository.ensureIsolatedRunningDirectory(instanceId); }; ExceptionalConsumer failure = ex -> { @@ -201,15 +197,11 @@ public static Task getInstallManuallyCreatedModpackTask(Path zipFile, String } public static Task getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, String iconUrl) { - repository.markInstanceAsModpack(instanceId); + repository.ensureIsolatedRunningDirectory(instanceId); ExceptionalRunnable success = () -> { repository.refresh(); - GameSettings.Instance setting = repository.getInstanceGameSettingsOrCreate(instanceId); - repository.undoMark(instanceId); - if (setting != null) { - setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); - } + repository.ensureIsolatedRunningDirectory(instanceId); }; ExceptionalConsumer failure = ex -> { 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 d6787691890..82635cf79ce 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -698,9 +698,9 @@ public void instanceOwnsHmclSpecificFiles(@TempDir Path tempDirectory) throws Ex Path sourceIcon = tempDirectory.resolve("source.png"); Files.write(sourceIcon, new byte[]{1, 2, 3}); instance.setIconFile(sourceIcon); - assertEquals(instance.getInstanceRoot().resolve("icon.png"), instance.getIconFile().orElseThrow()); + assertEquals(instance.getInstanceRoot().resolve("icon.png"), instance.getIconFile()); instance.deleteIconFile(); - assertTrue(instance.getIconFile().isEmpty()); + assertNull(instance.getIconFile()); } } 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 b9f0d974a0c..ef73fdf018b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -146,17 +146,6 @@ public GameInstanceID getId() { return id; } - /// Returns whether this instance is only a provisional placeholder. - /// - /// Provisional instances may appear in the current [DefaultGameRepositorySnapshot] so that - /// instance-local state (for example install-time settings) can be tracked before a real - /// manifest is saved. They must not be treated as indexed repository members. - /// - /// @return `false` by default - public boolean isProvisional() { - return false; - } - @Override public GameInstanceManifest getManifest() { return manifest; 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 a390bf49a25..3071d04c7da 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -397,8 +397,7 @@ public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInsta return getSnapshot().getRegistered(id); } - /// Returns the instance recorded in the current snapshot for the given id, including provisional - /// placeholders. + /// 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 @@ -427,7 +426,7 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { try { DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); DefaultGameInstance fromHolder = newSnapshot.get(from); - if (fromHolder == null || fromHolder.isProvisional()) { + if (fromHolder == null) { throw new NoSuchGameInstanceException(from); } @@ -528,7 +527,7 @@ public Optional getGameVersion(GameInstanceID instanceId) throws NoSuchG @Override public Optional getGameVersion(GameInstanceManifest manifest) { DefaultGameInstance instance = findSnapshotInstance(manifest.id()); - if (instance != null && !instance.isProvisional() && manifest.equals(instance.getManifest())) { + if (instance != null && manifest.equals(instance.getManifest())) { GameVersionNumber version = instance.getVersion(); if (version == GameVersionNumber.unknown()) { return Optional.empty(); 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 e77506e795b..5c9151c2bf0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -39,8 +39,7 @@ /// published snapshot, edit the copy, and publish it with /// [DefaultGameRepository#publishSnapshot(DefaultGameRepositorySnapshot)]. /// -/// Once sealed, this object is exposed as a [GameRepositorySnapshot]. Provisional placeholders -/// remain reachable through [#get(GameInstanceID)] but are excluded from the public snapshot view. +/// Once sealed, this object is exposed as a [GameRepositorySnapshot]. /// /// Mutation methods are package-private: only code in `org.jackhuang.hmcl.game` may assemble a /// snapshot. Subclasses such as HMCL-specific snapshots may override [#newEmpty()] to preserve @@ -104,7 +103,7 @@ public DefaultGameRepositoryLayout getLayout() { return layout; } - /// Returns the instance with the given id, including provisional placeholders. + /// Returns the instance with the given id. /// /// @param id the instance id /// @return the instance, or `null` when absent @@ -116,10 +115,10 @@ public DefaultGameRepositoryLayout getLayout() { /// /// @param id the instance id /// @return the registered instance - /// @throws NoSuchGameInstanceException if the instance is absent or provisional + /// @throws NoSuchGameInstanceException if the instance is absent public DefaultGameInstance getRegistered(GameInstanceID id) throws NoSuchGameInstanceException { DefaultGameInstance instance = instances.get(id); - if (instance != null && !instance.isProvisional()) { + if (instance != null) { return instance; } throw new NoSuchGameInstanceException(id); @@ -128,8 +127,7 @@ public DefaultGameInstance getRegistered(GameInstanceID id) throws NoSuchGameIns /// {@inheritDoc} @Override public boolean hasInstance(GameInstanceID instanceId) { - DefaultGameInstance instance = instances.get(instanceId); - return instance != null && !instance.isProvisional(); + return instances.containsKey(instanceId); } /// {@inheritDoc} @@ -141,43 +139,30 @@ public DefaultGameInstance getInstance(GameInstanceID instanceId) throws NoSuchG /// {@inheritDoc} @Override public @Nullable DefaultGameInstance findInstance(GameInstanceID instanceId) { - DefaultGameInstance instance = instances.get(instanceId); - if (instance != null && !instance.isProvisional()) { - return instance; - } - return null; + return instances.get(instanceId); } /// {@inheritDoc} @Override public int getInstanceCount() { - int count = 0; - for (DefaultGameInstance instance : instances.values()) { - if (!instance.isProvisional()) { - count++; - } - } - return count; + return instances.size(); } /// {@inheritDoc} @Override public Collection getInstances() { - return instances.values().stream() - .filter(instance -> !instance.isProvisional()) - .toList(); + return List.copyOf(instances.values()); } /// {@inheritDoc} @Override public Collection getInstanceManifests() { return instances.values().stream() - .filter(instance -> !instance.isProvisional()) .map(instance -> instance.manifest) .toList(); } - /// Returns a view of all instances in this snapshot, including provisional placeholders. + /// Returns a view of all instances in this snapshot. /// /// @return the instances; unmodifiable after [#seal()] public Collection values() { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java index f285a228fc9..84c26a35574 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositorySnapshot.java @@ -33,8 +33,7 @@ /// publishes a newer snapshot, previously obtained instances may be stale; request them again from /// the current snapshot or repository when up-to-date state is required. /// -/// Snapshot queries describe **registered** instances only. Implementation-specific provisional -/// placeholders used during installation are not part of this view. +/// Snapshot queries describe the instances indexed at publish time. @NotNullByDefault public interface GameRepositorySnapshot { /// Returns the repository that published this snapshot. From 40c2617377321eecbda8d58b0350af359d0e1573 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 21:02:37 +0800 Subject: [PATCH 075/199] refactor(HMCLGameInstance, GameAdvancedListItem): improve icon image handling with weak references and caching --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 110 ++++++++++++++++-- .../hmcl/game/HMCLGameRepository.java | 5 - .../ui/instances/GameAdvancedListItem.java | 35 ++++-- .../ui/instances/GameInstanceIconDialog.java | 3 +- 4 files changed, 123 insertions(+), 30 deletions(-) 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 51662bfcc14..c4fdeaa4de8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -20,6 +20,8 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import com.google.gson.reflect.TypeToken; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.property.ReadOnlyObjectPropertyBase; import javafx.scene.image.Image; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.LibraryAnalyzer; @@ -41,6 +43,8 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.lang.ref.SoftReference; +import java.lang.ref.WeakReference; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -65,6 +69,12 @@ public class HMCLGameInstance extends DefaultGameInstance { /// Cached instance-local game settings, or `null` when none exist after loading. private GameSettings.@Nullable Instance gameSettings; + /// Soft-cached icon image for this instance id. + /// + /// Shared across COW snapshot wrappers. The computed [Image] is retained only via a + /// [SoftReference], so it can be reclaimed under memory pressure when nothing else holds it. + private final WeakCachedIconImageProperty iconImage; + /// Creates a registered instance bound to the given repository snapshot. /// /// @param snapshot the repository snapshot that owns this instance @@ -86,12 +96,13 @@ protected HMCLGameInstance( GameInstanceManifest manifest, @Nullable Path manifestFile) { super(snapshot, id, manifest, manifestFile); + this.iconImage = new WeakCachedIconImageProperty(getRepository(), id); } /// Creates an instance that shares mutable instance-local state with another instance. /// - /// Used when the repository clones a snapshot so that settings remain available on the new - /// wrapper. + /// Used when the repository clones a snapshot so that settings and the icon property remain + /// available on the new wrapper. private HMCLGameInstance( DefaultGameRepositorySnapshot snapshot, GameInstanceID id, @@ -101,6 +112,7 @@ private HMCLGameInstance( this.gameSettingsLoaded = shareState.gameSettingsLoaded; this.gameSettingsReadOnly = shareState.gameSettingsReadOnly; this.gameSettings = shareState.gameSettings; + this.iconImage = shareState.iconImage; } @Override @@ -303,6 +315,7 @@ public GameSettings.Instance initSettings(GameSettings.Instance setting, boolean setting.setSavable(allowSave); gameSettingsLoaded = true; gameSettings = setting; + setting.iconProperty().addListener(observable -> invalidateIconImage()); if (allowSave) { gameSettingsReadOnly = false; setting.addListener(a -> saveSettings()); @@ -354,14 +367,20 @@ public void setIconFile(Path iconFile) throws IOException { throw new IllegalArgumentException("Unsupported icon file: " + extension); } - deleteIconFile(); + clearIconFiles(); FileUtils.copyFile(iconFile, getInstanceRoot().resolve("icon." + extension)); + invalidateIconImage(); } /// Deletes all supported custom icon files for this instance. /// /// Individual deletion failures are logged and do not stop later files from being attempted. public void deleteIconFile() { + clearIconFiles(); + invalidateIconImage(); + } + + private void clearIconFiles() { for (String extension : FXUtils.IMAGE_EXTENSIONS) { Path file = getInstanceRoot().resolve("icon." + extension); try { @@ -372,18 +391,41 @@ public void deleteIconFile() { } } + /// Returns the observable icon image for this instance. + /// + /// The image is stored in a [SoftReference] cache: when nothing else strongly references it + /// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory + /// pressure. The next [#getIconImage] reloads it. + /// + /// @return the icon image property + public ReadOnlyObjectProperty iconImageProperty() { + return iconImage; + } + /// Returns the icon image selected for this instance. /// - /// The configured built-in icon takes precedence. When the default icon is selected, this method - /// tries a custom icon file and then derives a built-in icon from the instance manifest. + /// Equivalent to [ReadOnlyObjectProperty#get()] on [#iconImageProperty]. /// /// @return the selected or derived icon image public Image getIconImage() { - if (!getRepository().isLoaded()) { + return iconImage.get(); + } + + /// Drops the soft-cached icon image and notifies observers. + public void invalidateIconImage() { + iconImage.invalidate(); + } + + /// Computes the icon image from settings, custom files, and the launch manifest. + /// + /// @param instance the instance to inspect; must be a current snapshot member when possible + /// @return the selected or derived icon image + private static Image computeIconImage(HMCLGameInstance instance) { + if (!instance.getRepository().isLoaded()) { return GameInstanceIconType.DEFAULT.getIcon(); } - @Nullable GameSettings.Instance setting = getSettings(); + @Nullable GameSettings.Instance setting = instance.getSettings(); GameInstanceIconType iconType = setting != null ? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT) : GameInstanceIconType.DEFAULT; @@ -391,16 +433,16 @@ public Image getIconImage() { return iconType.getIcon(); } - @Nullable Path iconFile = getIconFile(); + @Nullable Path iconFile = instance.getIconFile(); if (iconFile != null) { try { return FXUtils.loadImage(iconFile, 64, 64, true, true); } catch (Exception e) { - LOG.warning("Failed to load instance icon for " + id, e); + LOG.warning("Failed to load instance icon for " + instance.getId(), e); } } - GameInstanceManifest.Resolved resolvedManifest = getResolvedManifest(); + GameInstanceManifest.Resolved resolvedManifest = instance.getResolvedManifest(); if (LibraryAnalyzer.isModded(resolvedManifest)) { LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, null); if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) @@ -421,7 +463,7 @@ else if (analyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) return GameInstanceIconType.OPTIFINE.getIcon(); } - @Nullable String gameVersion = getRepository().getGameVersion(getLaunchManifest()).orElse(null); + @Nullable String gameVersion = instance.getRepository().getGameVersion(instance.getLaunchManifest()).orElse(null); if (gameVersion != null) { GameVersionNumber version = GameVersionNumber.asGameVersion(gameVersion); if (version.isAprilFools()) { @@ -435,6 +477,52 @@ else if (analyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) return GameInstanceIconType.GRASS.getIcon(); } + /// Soft-cached read-only icon property compatible with JavaFX versions before 19. + private static final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase { + private final HMCLGameRepository repository; + private final GameInstanceID instanceId; + private @Nullable WeakReference cache; + + /// @param repository the repository that owns the instance + /// @param instanceId the instance id + WeakCachedIconImageProperty(HMCLGameRepository repository, GameInstanceID instanceId) { + this.repository = repository; + this.instanceId = instanceId; + } + + @Override + public Object getBean() { + return repository; + } + + @Override + public String getName() { + return "iconImage"; + } + + @Override + public Image get() { + WeakReference current = cache; + Image image = current != null ? current.get() : null; + if (image != null) { + return image; + } + + HMCLGameInstance instance = repository.findInstance(instanceId); + image = instance != null + ? computeIconImage(instance) + : GameInstanceIconType.DEFAULT.getIcon(); + cache = new WeakReference<>(image); + return image; + } + + /// Clears the weak cache and notifies listeners. + void invalidate() { + cache = null; + fireValueChangedEvent(); + } + } + /// Creates the marker indicating that the most recent launch ended abnormally. public void markLaunchedAbnormally() { try { 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 070e81cd747..c6784f43dbe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -23,8 +23,6 @@ import javafx.beans.property.ReadOnlyObjectWrapper; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.event.Event; -import org.jackhuang.hmcl.event.EventManager; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.setting.SettingsManager; @@ -66,9 +64,6 @@ public final class HMCLGameRepository extends DefaultGameRepository { /// The selected instance resolved from the current repository snapshot. private final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance; - /// Publishes notifications after an instance icon changes. - public final EventManager onInstanceIconChanged = new EventManager<>(); - /// Creates a repository backed by the given game directory. /// /// @param gameDirectory the persistent game directory represented by this repository diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java index 2bdea2ee71e..bfceb45bed7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameAdvancedListItem.java @@ -17,10 +17,12 @@ */ package org.jackhuang.hmcl.ui.instances; +import javafx.beans.property.ReadOnlyObjectProperty; +import javafx.beans.value.ChangeListener; +import javafx.beans.value.WeakChangeListener; import javafx.geometry.Pos; -import org.jackhuang.hmcl.event.Event; +import javafx.scene.image.Image; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.ui.FXUtils; @@ -29,19 +31,21 @@ import org.jackhuang.hmcl.ui.construct.ImageContainer; import org.jetbrains.annotations.Nullable; -import java.util.function.Consumer; - import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class GameAdvancedListItem extends AdvancedListItem { private final ImageContainer imageContainer; private final WeakListenerHolder holder = new WeakListenerHolder(); - private @Nullable HMCLGameRepository repository; - @SuppressWarnings("unused") - private @Nullable Consumer onInstanceIconChangedListener; + + /// Strongly held so [WeakChangeListener] keeps delivering icon updates. + private final ChangeListener iconListener; + + private @Nullable WeakChangeListener weakIconListener; + private @Nullable ReadOnlyObjectProperty observedIcon; public GameAdvancedListItem() { this.imageContainer = new ImageContainer(LEFT_GRAPHIC_SIZE); + this.iconListener = (observable, oldImage, newImage) -> imageContainer.setImage(newImage); imageContainer.setMouseTransparent(true); AdvancedListItem.setAlignment(imageContainer, Pos.CENTER); setLeftGraphic(imageContainer); @@ -50,14 +54,13 @@ public GameAdvancedListItem() { } private void loadInstance(@Nullable HMCLGameInstance instance) { - if (GameDirectoryManager.getSelectedRepository() != repository) { - repository = GameDirectoryManager.getSelectedRepository(); - onInstanceIconChangedListener = repository.onInstanceIconChanged.registerWeak(event -> - FXUtils.runInFX(() -> loadInstance(repository.getSelectedInstance()))); - } + unbindIcon(); if (instance != null) { setTitle(i18n("instance.manage.manage")); setSubtitle(instance.getId().toString()); + observedIcon = instance.iconImageProperty(); + weakIconListener = new WeakChangeListener<>(iconListener); + observedIcon.addListener(weakIconListener); imageContainer.setImage(instance.getIconImage()); return; } @@ -66,4 +69,12 @@ private void loadInstance(@Nullable HMCLGameInstance instance) { setSubtitle(i18n("instance.empty.add")); imageContainer.setImage(GameInstanceIconType.DEFAULT.getIcon()); } + + private void unbindIcon() { + if (observedIcon != null && weakIconListener != null) { + observedIcon.removeListener(weakIconListener); + } + observedIcon = null; + weakIconListener = null; + } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java index aafc10de715..77fda7e3d69 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstanceIconDialog.java @@ -21,7 +21,6 @@ import javafx.scene.image.ImageView; import javafx.scene.layout.FlowPane; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.event.Event; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.setting.GameInstanceIconType; @@ -117,7 +116,7 @@ private Node createIcon(GameInstanceIconType type) { @Override protected void onAccept() { - gameInstance.getRepository().onInstanceIconChanged.fireEvent(new Event(this)); + // Icon file / settings.iconProperty updates already invalidate iconImageProperty. onFinish.run(); super.onAccept(); } From b52a45006c399b037d3df442e64a1f8d2124c55f Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 21:09:39 +0800 Subject: [PATCH 076/199] refactor(HMCLGameInstance): enhance icon image caching and retrieval logic --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 155 ++++++++---------- 1 file changed, 69 insertions(+), 86 deletions(-) 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 c4fdeaa4de8..523a4c75e96 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -69,12 +69,6 @@ public class HMCLGameInstance extends DefaultGameInstance { /// Cached instance-local game settings, or `null` when none exist after loading. private GameSettings.@Nullable Instance gameSettings; - /// Soft-cached icon image for this instance id. - /// - /// Shared across COW snapshot wrappers. The computed [Image] is retained only via a - /// [SoftReference], so it can be reclaimed under memory pressure when nothing else holds it. - private final WeakCachedIconImageProperty iconImage; - /// Creates a registered instance bound to the given repository snapshot. /// /// @param snapshot the repository snapshot that owns this instance @@ -96,7 +90,6 @@ protected HMCLGameInstance( GameInstanceManifest manifest, @Nullable Path manifestFile) { super(snapshot, id, manifest, manifestFile); - this.iconImage = new WeakCachedIconImageProperty(getRepository(), id); } /// Creates an instance that shares mutable instance-local state with another instance. @@ -112,7 +105,6 @@ private HMCLGameInstance( this.gameSettingsLoaded = shareState.gameSettingsLoaded; this.gameSettingsReadOnly = shareState.gameSettingsReadOnly; this.gameSettings = shareState.gameSettings; - this.iconImage = shareState.iconImage; } @Override @@ -391,6 +383,12 @@ private void clearIconFiles() { } } + /// Soft-cached icon image for this instance id. + /// + /// Shared across COW snapshot wrappers. The computed [Image] is retained only via a + /// [SoftReference], so it can be reclaimed under memory pressure when nothing else holds it. + private @Nullable WeakCachedIconImageProperty iconImage; + /// Returns the observable icon image for this instance. /// /// The image is stored in a [SoftReference] cache: when nothing else strongly references it @@ -399,6 +397,9 @@ private void clearIconFiles() { /// /// @return the icon image property public ReadOnlyObjectProperty iconImageProperty() { + if (iconImage == null) { + iconImage = new WeakCachedIconImageProperty(); + } return iconImage; } @@ -408,91 +409,21 @@ public ReadOnlyObjectProperty iconImageProperty() { /// /// @return the selected or derived icon image public Image getIconImage() { - return iconImage.get(); + return iconImageProperty().get(); } /// Drops the soft-cached icon image and notifies observers. public void invalidateIconImage() { - iconImage.invalidate(); - } - - /// Computes the icon image from settings, custom files, and the launch manifest. - /// - /// @param instance the instance to inspect; must be a current snapshot member when possible - /// @return the selected or derived icon image - private static Image computeIconImage(HMCLGameInstance instance) { - if (!instance.getRepository().isLoaded()) { - return GameInstanceIconType.DEFAULT.getIcon(); - } - - @Nullable GameSettings.Instance setting = instance.getSettings(); - GameInstanceIconType iconType = setting != null - ? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT) - : GameInstanceIconType.DEFAULT; - if (iconType != GameInstanceIconType.DEFAULT) { - return iconType.getIcon(); - } - - @Nullable Path iconFile = instance.getIconFile(); - if (iconFile != null) { - try { - return FXUtils.loadImage(iconFile, 64, 64, true, true); - } catch (Exception e) { - LOG.warning("Failed to load instance icon for " + instance.getId(), e); - } - } - - GameInstanceManifest.Resolved resolvedManifest = instance.getResolvedManifest(); - if (LibraryAnalyzer.isModded(resolvedManifest)) { - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, null); - if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) - return GameInstanceIconType.FABRIC.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) - return GameInstanceIconType.QUILT.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) - return GameInstanceIconType.LEGACY_FABRIC.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) - return GameInstanceIconType.NEO_FORGE.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) - return GameInstanceIconType.FORGE.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) - return GameInstanceIconType.CLEANROOM.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) - return GameInstanceIconType.CHICKEN.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) - return GameInstanceIconType.OPTIFINE.getIcon(); - } - - @Nullable String gameVersion = instance.getRepository().getGameVersion(instance.getLaunchManifest()).orElse(null); - if (gameVersion != null) { - GameVersionNumber version = GameVersionNumber.asGameVersion(gameVersion); - if (version.isAprilFools()) { - return GameInstanceIconType.APRIL_FOOLS.getIcon(); - } else if (version instanceof GameVersionNumber.LegacySnapshot) { - return GameInstanceIconType.COMMAND.getIcon(); - } else if (version instanceof GameVersionNumber.Old) { - return GameInstanceIconType.CRAFT_TABLE.getIcon(); - } - } - return GameInstanceIconType.GRASS.getIcon(); + ((WeakCachedIconImageProperty) iconImageProperty()).invalidate(); } /// Soft-cached read-only icon property compatible with JavaFX versions before 19. - private static final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase { - private final HMCLGameRepository repository; - private final GameInstanceID instanceId; + private final class WeakCachedIconImageProperty extends ReadOnlyObjectPropertyBase { private @Nullable WeakReference cache; - /// @param repository the repository that owns the instance - /// @param instanceId the instance id - WeakCachedIconImageProperty(HMCLGameRepository repository, GameInstanceID instanceId) { - this.repository = repository; - this.instanceId = instanceId; - } - @Override public Object getBean() { - return repository; + return HMCLGameInstance.this; } @Override @@ -508,14 +439,66 @@ public Image get() { return image; } - HMCLGameInstance instance = repository.findInstance(instanceId); - image = instance != null - ? computeIconImage(instance) - : GameInstanceIconType.DEFAULT.getIcon(); + image = computeIconImage(); cache = new WeakReference<>(image); return image; } + /// Computes the icon image from settings, custom files, and the launch manifest. + /// + /// @return the selected or derived icon image + private Image computeIconImage() { + @Nullable GameSettings.Instance setting = getSettings(); + GameInstanceIconType iconType = setting != null + ? Lang.requireNonNullElse(setting.iconProperty().getValue(), GameInstanceIconType.DEFAULT) + : GameInstanceIconType.DEFAULT; + if (iconType != GameInstanceIconType.DEFAULT) { + return iconType.getIcon(); + } + + @Nullable Path iconFile = getIconFile(); + if (iconFile != null) { + try { + return FXUtils.loadImage(iconFile, 64, 64, true, true); + } catch (Exception e) { + LOG.warning("Failed to load instance icon for " + getId(), e); + } + } + + GameInstanceManifest.Resolved resolvedManifest = getResolvedManifest(); + if (LibraryAnalyzer.isModded(resolvedManifest)) { + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, null); + if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) + return GameInstanceIconType.FABRIC.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) + return GameInstanceIconType.QUILT.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) + return GameInstanceIconType.LEGACY_FABRIC.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) + return GameInstanceIconType.NEO_FORGE.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) + return GameInstanceIconType.FORGE.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) + return GameInstanceIconType.CLEANROOM.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) + return GameInstanceIconType.CHICKEN.getIcon(); + else if (analyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) + return GameInstanceIconType.OPTIFINE.getIcon(); + } + + GameVersionNumber version = getVersion(); + if (!version.equals(GameVersionNumber.unknown())) { + if (version.isAprilFools()) { + return GameInstanceIconType.APRIL_FOOLS.getIcon(); + } else if (version instanceof GameVersionNumber.LegacySnapshot) { + return GameInstanceIconType.COMMAND.getIcon(); + } else if (version instanceof GameVersionNumber.Old) { + return GameInstanceIconType.CRAFT_TABLE.getIcon(); + } + } + return GameInstanceIconType.GRASS.getIcon(); + } + /// Clears the weak cache and notifies listeners. void invalidate() { cache = null; From 047b9d86aab484c20f5c9000a9e4d7a022ad9ee9 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 21:11:56 +0800 Subject: [PATCH 077/199] refactor(GameRepository): remove unused getResourcePackDirectory method --- .../main/java/org/jackhuang/hmcl/game/GameRepository.java | 8 -------- 1 file changed, 8 deletions(-) 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 c45e8f6a900..555e28b23cb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -149,14 +149,6 @@ default Path getModsDirectory(GameInstanceID instanceId) { return getRunDirectory(instanceId).resolve("mods"); } - /// Returns the resource pack directory for an instance. - /// - /// @param instanceId the instance id - /// @return the resource pack directory below the run directory - default Path getResourcePackDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("resourcepacks"); - } - /// Returns the primary client jar path for a manifest. /// /// @param manifest the manifest whose jar should be located From e0122bab620517ba64489333c0c495751c9eb5ff Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 21:18:15 +0800 Subject: [PATCH 078/199] refactor(DefaultDependencyManager): rename variable for clarity in installLibraryAsync method --- .../hmcl/download/DefaultDependencyManager.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 f1e4a5d4c78..cd0950298e0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -182,18 +182,18 @@ public Task installLibraryAsync(String gameVersion, GameIn @Override public Task installLibraryAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion) { - AtomicReference removedLibraryVersion = new AtomicReference<>(); + AtomicReference removedLibraryManifest = new AtomicReference<>(); return removeLibraryAsync(baseVersion, libraryVersion.getLibraryId()) - .thenComposeAsync(version -> { - removedLibraryVersion.set(version); - return libraryVersion.getInstallTask(this, version); + .thenComposeAsync(manifest -> { + removedLibraryManifest.set(manifest); + return libraryVersion.getInstallTask(this, manifest); }) .thenApplyAsync(patch -> { if (patch == null) { - return removedLibraryVersion.get(); + return removedLibraryManifest.get(); } else { - return removedLibraryVersion.get().addPatch(patch); + return removedLibraryManifest.get().addPatch(patch); } }) .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getLibraryId(), libraryVersion.getSelfVersion())); From ce3c166e005eeb1f754ee1d32ba3ea9f8dcc819a Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 21:33:00 +0800 Subject: [PATCH 079/199] Remove getRunDirectory from GameRepository in favor of instance and resolveRunDirectory Assisted-by: grok-build:grok-4.5 --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 2 +- .../hmcl/game/HMCLGameRepository.java | 28 +++++++++++-------- .../hmcl/game/HMCLModpackInstallTask.java | 2 +- .../org/jackhuang/hmcl/game/LogExporter.java | 2 +- .../jackhuang/hmcl/ui/GameCrashWindow.java | 2 +- .../hmcl/setting/GameDirectoriesTest.java | 9 +++--- .../download/fabric/FabricAPIInstallTask.java | 2 +- .../LegacyFabricAPIInstallTask.java | 2 +- .../download/quilt/QuiltAPIInstallTask.java | 2 +- .../hmcl/game/DefaultGameInstance.java | 3 +- .../hmcl/game/DefaultGameRepository.java | 17 +++++++++-- .../jackhuang/hmcl/game/GameRepository.java | 14 ---------- .../hmcl/modpack/curse/CurseInstallTask.java | 2 +- .../mcbbs/McbbsModpackLocalInstallTask.java | 2 +- .../modpack/modrinth/ModrinthInstallTask.java | 2 +- .../multimc/MultiMCModpackInstallTask.java | 2 +- .../server/ServerModpackLocalInstallTask.java | 2 +- 17 files changed, 50 insertions(+), 45 deletions(-) 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 523a4c75e96..5d39fffecc3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -160,7 +160,7 @@ public boolean isModpack() { @Override public Path getRunDirectory() { - return getRepository().resolveRunDirectory(getId(), isModpack(), getSettings()); + return getRepository().computeRunDirectory(getId(), isModpack(), getSettings()); } /// Returns the loaded instance-local game settings, loading them on first access. 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 c6784f43dbe..eff4aa70d7f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -199,23 +199,27 @@ public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) return new DefaultDependencyManager(this, downloadProvider, HMCLCacheRepository.REPOSITORY); } + /// {@inheritDoc} + /// + /// When the instance is not yet registered, isolation is resolved from on-disk settings and + /// `modpack.cfg` so install tasks can target the correct directory before `save`/`refresh`. @Override - public Path getRunDirectory(GameInstanceID instanceId) { + public Path resolveRunDirectory(GameInstanceID instanceId) { HMCLGameInstance instance = findInstance(instanceId); if (instance != null) { return instance.getRunDirectory(); } boolean modpack = Files.exists(getLayout().getModpackConfigurationFile(instanceId)); - return resolveRunDirectory(instanceId, modpack, peekInstanceGameSettings(instanceId)); + return computeRunDirectory(instanceId, modpack, peekInstanceGameSettings(instanceId)); } - /// Resolves the run directory for an instance id from modpack state and local settings. + /// Resolves the run directory from modpack state and local settings. /// - /// @param instanceId the instance id - /// @param modpack whether the instance is an HMCL modpack (`modpack.cfg` present) + /// @param instanceId the instance id + /// @param modpack whether the instance is an HMCL modpack (`modpack.cfg` present) /// @param localSetting the instance-local settings, or `null` when absent /// @return the run directory - Path resolveRunDirectory( + Path computeRunDirectory( GameInstanceID instanceId, boolean modpack, GameSettings.@Nullable Instance localSetting) { @@ -292,7 +296,7 @@ private void writeInstanceGameSettings(GameInstanceID instanceId, GameSettings.I /// /// When the instance is already registered, settings are updated through /// [HMCLGameInstance]. Otherwise the isolation flag is written to the instance settings file - /// so install tasks that call [#getRunDirectory(GameInstanceID)] see the isolated path. + /// so install tasks that call [#resolveRunDirectory(GameInstanceID)] see the isolated path. /// /// @param instanceId the instance id public void ensureIsolatedRunningDirectory(GameInstanceID instanceId) { @@ -342,7 +346,7 @@ private void clean(Path directory) throws IOException { public void clean(GameInstanceID instanceId) throws IOException { clean(getBaseDirectory()); - clean(getRunDirectory(instanceId)); + clean(resolveRunDirectory(instanceId)); } public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException { @@ -376,19 +380,19 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea boolean copyOriginalGameDir; try { - copyOriginalGameDir = !Files.isSameFile(getRunDirectory(srcId), getLayout().getInstanceRoot(srcId)); + copyOriginalGameDir = !Files.isSameFile(resolveRunDirectory(srcId), getLayout().getInstanceRoot(srcId)); } catch (IOException e) { copyOriginalGameDir = true; } - Path srcGameDir = getRunDirectory(srcId); + Path srcGameDir = resolveRunDirectory(srcId); GameSettings.Instance newGameSettings = getInstance(srcId).copySettings(); newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); newGameSettings.runningDirectoryProperty().setValue(""); writeInstanceGameSettings(dstId, newGameSettings); - Path dstGameDir = getRunDirectory(dstId); + Path dstGameDir = resolveRunDirectory(dstId); if (copyOriginalGameDir) FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null)); @@ -460,7 +464,7 @@ 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 - /// [#getRunDirectory(GameInstanceID)] returns the instance root without requiring a snapshot + /// [#resolveRunDirectory(GameInstanceID)] returns the instance root without requiring a snapshot /// member. public void applyDefaultIsolationSettingForNewInstance(GameInstanceID instanceId, boolean modded) { if (!shouldIsolateNewInstance(modded)) { 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 30365909d75..08eb34db9ae 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -51,7 +51,7 @@ public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpa this.instanceId = instanceId; this.modpack = modpack; - Path run = repository.getRunDirectory(this.instanceId); + Path run = repository.resolveRunDirectory(this.instanceId); Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId); if (repository.hasInstance(this.instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java index 90643edea93..5b6f61395d1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -43,7 +43,7 @@ private LogExporter() { public static CompletableFuture exportLogs( Path zipFile, DefaultGameRepository repository, GameInstanceID instanceId, String logs, String launchScript, PathMatcher logMatcher) { - Path runDirectory = repository.getRunDirectory(instanceId); + Path runDirectory = repository.resolveRunDirectory(instanceId); Path baseDirectory = repository.getBaseDirectory(); List instances = new ArrayList<>(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index 4d1a23cf116..fd059631249 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -142,7 +142,7 @@ private void analyzeCrashReport() { return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>()); }), Task.supplyAsync(() -> { - Path latestLog = repository.getRunDirectory(manifest.id()).resolve("logs/latest.log"); + Path latestLog = repository.resolveRunDirectory(manifest.id()).resolve("logs/latest.log"); if (!Files.isReadable(latestLog)) { return pair(new HashSet(), new HashSet()); } 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 82635cf79ce..6641431eb2e 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -464,15 +464,16 @@ public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@Tem GameInstanceID id = new GameInstanceID("1.21.11-fabric"); assertFalse(repository.hasInstance(id)); - assertEquals(repository.getBaseDirectory(), repository.getRunDirectory(id)); + assertEquals(repository.getBaseDirectory(), repository.resolveRunDirectory(id)); repository.applyDefaultIsolationSettingForNewInstance(id, true); - assertEquals(repository.getLayout().getInstanceRoot(id), repository.getRunDirectory(id)); - assertEquals(repository.getLayout().getInstanceRoot(id).resolve("mods"), repository.getModsDirectory(id)); + assertEquals(repository.getLayout().getInstanceRoot(id), repository.resolveRunDirectory(id)); + assertEquals(repository.getLayout().getInstanceRoot(id).resolve("mods"), + repository.resolveRunDirectory(id).resolve("mods")); assertTrue(repository.removeInstanceFromDisk(id)); - assertEquals(repository.getBaseDirectory(), repository.getRunDirectory(id)); + assertEquals(repository.getBaseDirectory(), repository.resolveRunDirectory(id)); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java index 6e3c67e8521..0fe207888d7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java @@ -60,7 +60,7 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - dependencyManager.getGameRepository().getModsDirectory(manifest.id()).resolve("fabric-api-" + remote.getVersion().version() + ".jar"), + dependencyManager.getGameRepository().resolveRunDirectory(manifest.id()).resolve("mods").resolve("fabric-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java index d11361ff765..dfa0013ff5a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java @@ -55,7 +55,7 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - dependencyManager.getGameRepository().getModsDirectory(manifest.id()).resolve("legacy-fabric-api-" + remote.getVersion().version() + ".jar"), + dependencyManager.getGameRepository().resolveRunDirectory(manifest.id()).resolve("mods").resolve("legacy-fabric-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java index 1bacd55e612..26e4450a8a2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java @@ -60,7 +60,7 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - dependencyManager.getGameRepository().getModsDirectory(manifest.id()).resolve("quilt-api-" + remote.getVersion().version() + ".jar"), + dependencyManager.getGameRepository().resolveRunDirectory(manifest.id()).resolve("mods").resolve("quilt-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } 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 ef73fdf018b..6ae68659e34 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -274,7 +274,8 @@ Path getOwnJarFile() { @Override public Path getRunDirectory() { - return getRepository().getRunDirectory(id); + // Official layout: shared working directory is the repository base directory. + return getRepository().getBaseDirectory(); } /// {@inheritDoc} 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 3071d04c7da..e615d0cc641 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -405,8 +405,21 @@ public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInsta return getSnapshot().get(id); } - @Override - public Path getRunDirectory(GameInstanceID instanceId) { + /// Resolves the run directory for an instance id. + /// + /// When the id is present in the current snapshot, this returns + /// [DefaultGameInstance#getRunDirectory]. Otherwise this returns the repository base directory + /// (shared run directory of the official layout). Install tasks and other id-based callers that + /// do not yet hold a [GameInstance] should use this method instead of a repository-level + /// `getRunDirectory` API. + /// + /// @param instanceId the instance id + /// @return the run directory + public Path resolveRunDirectory(GameInstanceID instanceId) { + DefaultGameInstance instance = findSnapshotInstance(instanceId); + if (instance != null) { + return instance.getRunDirectory(); + } return getBaseDirectory(); } 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 555e28b23cb..06f6dafcf66 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -135,20 +135,6 @@ default Path getInstanceRoot(GameInstanceID instanceId) { return getLayout().getInstanceRoot(instanceId); } - /// Returns the working directory used when launching an instance. - /// - /// @param instanceId the instance id - /// @return the run directory - Path getRunDirectory(GameInstanceID instanceId); - - /// Returns the mods directory for an instance. - /// - /// @param instanceId the instance id - /// @return the mods directory below the run directory - default Path getModsDirectory(GameInstanceID instanceId) { - return getRunDirectory(instanceId).resolve("mods"); - } - /// Returns the primary client jar path for a manifest. /// /// @param manifest the manifest whose jar should be located 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 1c9a6116a1d..20096137c3b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java @@ -77,7 +77,7 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile this.iconUrl = iconUrl; this.repository = dependencyManager.getGameRepository(); - this.run = repository.getRunDirectory(instanceId); + this.run = repository.resolveRunDirectory(instanceId); Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) 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 f594b7270eb..df6fc459d0d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java @@ -59,7 +59,7 @@ public McbbsModpackLocalInstallTask(DefaultDependencyManager dependencyManager, this.manifest = manifest; this.instanceId = instanceId; this.repository = dependencyManager.getGameRepository(); - Path run = repository.getRunDirectory(instanceId); + Path run = repository.resolveRunDirectory(instanceId); Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) 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 c8975025883..6f1250be52f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java @@ -62,7 +62,7 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF this.instanceId = instanceId; this.iconUrl = iconUrl; this.repository = dependencyManager.getGameRepository(); - this.run = repository.getRunDirectory(instanceId); + this.run = repository.resolveRunDirectory(instanceId); Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) 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 5167f53def4..019b6756af1 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 @@ -109,7 +109,7 @@ public boolean doPreExecute() { public void preExecute() throws Exception { // Stage #0: General Setup { - Path run = repository.getRunDirectory(instanceId); + Path run = repository.resolveRunDirectory(instanceId); Path json = repository.getLayout().getModpackConfigurationFile(instanceId); ModpackConfiguration config = null; 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 10ed8cb7923..2ad581831b0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java @@ -52,7 +52,7 @@ public ServerModpackLocalInstallTask(DefaultDependencyManager dependencyManager, this.manifest = manifest; this.instanceId = instanceId; this.repository = dependencyManager.getGameRepository(); - Path run = repository.getRunDirectory(instanceId); + Path run = repository.resolveRunDirectory(instanceId); Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) From c5546045cd79f79b95b3af131a6d920f1adfde02 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 5 Aug 2026 21:40:36 +0800 Subject: [PATCH 080/199] refactor(GameRepository, InstallTasks): replace resolveRunDirectory with instance-specific path retrieval --- .../hmcl/game/HMCLGameRepository.java | 30 +++++-------------- .../hmcl/game/HMCLModpackInstallTask.java | 2 +- .../org/jackhuang/hmcl/game/LogExporter.java | 3 +- .../jackhuang/hmcl/ui/GameCrashWindow.java | 6 +++- .../hmcl/setting/GameDirectoriesTest.java | 17 ++++++----- .../hmcl/download/DefaultGameBuilder.java | 5 +++- .../download/fabric/FabricAPIInstallTask.java | 14 ++++++++- .../LegacyFabricAPIInstallTask.java | 14 ++++++++- .../download/quilt/QuiltAPIInstallTask.java | 14 ++++++++- .../hmcl/game/DefaultGameRepository.java | 18 ----------- .../hmcl/modpack/curse/CurseInstallTask.java | 2 +- .../mcbbs/McbbsModpackLocalInstallTask.java | 2 +- .../modpack/modrinth/ModrinthInstallTask.java | 2 +- .../multimc/MultiMCModpackInstallTask.java | 2 +- .../server/ServerModpackLocalInstallTask.java | 2 +- 15 files changed, 72 insertions(+), 61 deletions(-) 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 eff4aa70d7f..f1d1f6aa6e8 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -199,20 +199,6 @@ public DefaultDependencyManager getDependency(DownloadProvider downloadProvider) return new DefaultDependencyManager(this, downloadProvider, HMCLCacheRepository.REPOSITORY); } - /// {@inheritDoc} - /// - /// When the instance is not yet registered, isolation is resolved from on-disk settings and - /// `modpack.cfg` so install tasks can target the correct directory before `save`/`refresh`. - @Override - public Path resolveRunDirectory(GameInstanceID instanceId) { - HMCLGameInstance instance = findInstance(instanceId); - if (instance != null) { - return instance.getRunDirectory(); - } - boolean modpack = Files.exists(getLayout().getModpackConfigurationFile(instanceId)); - return computeRunDirectory(instanceId, modpack, peekInstanceGameSettings(instanceId)); - } - /// Resolves the run directory from modpack state and local settings. /// /// @param instanceId the instance id @@ -296,7 +282,7 @@ private void writeInstanceGameSettings(GameInstanceID instanceId, GameSettings.I /// /// When the instance is already registered, settings are updated through /// [HMCLGameInstance]. Otherwise the isolation flag is written to the instance settings file - /// so install tasks that call [#resolveRunDirectory(GameInstanceID)] see the isolated path. + /// so a later [HMCLGameInstance#getRunDirectory] sees the isolated path. /// /// @param instanceId the instance id public void ensureIsolatedRunningDirectory(GameInstanceID instanceId) { @@ -346,7 +332,7 @@ private void clean(Path directory) throws IOException { public void clean(GameInstanceID instanceId) throws IOException { clean(getBaseDirectory()); - clean(resolveRunDirectory(instanceId)); + clean(getInstance(instanceId).getRunDirectory()); } public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException { @@ -378,21 +364,20 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea JsonUtils.writeToJsonFile(toJson, fromManifest.withId(dstId).withJar(dstId)); + Path srcGameDir = getInstance(srcId).getRunDirectory(); boolean copyOriginalGameDir; try { - copyOriginalGameDir = !Files.isSameFile(resolveRunDirectory(srcId), getLayout().getInstanceRoot(srcId)); + copyOriginalGameDir = !Files.isSameFile(srcGameDir, getLayout().getInstanceRoot(srcId)); } catch (IOException e) { copyOriginalGameDir = true; } - Path srcGameDir = resolveRunDirectory(srcId); - GameSettings.Instance newGameSettings = getInstance(srcId).copySettings(); newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); newGameSettings.runningDirectoryProperty().setValue(""); writeInstanceGameSettings(dstId, newGameSettings); - Path dstGameDir = resolveRunDirectory(dstId); + Path dstGameDir = computeRunDirectory(dstId, false, newGameSettings); if (copyOriginalGameDir) FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null)); @@ -463,9 +448,8 @@ 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 - /// [#resolveRunDirectory(GameInstanceID)] returns the instance root without requiring a snapshot - /// member. + /// 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; 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 08eb34db9ae..3fc359f1f27 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -51,7 +51,7 @@ public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpa this.instanceId = instanceId; this.modpack = modpack; - Path run = repository.resolveRunDirectory(this.instanceId); + Path run = repository.getLayout().getInstanceRoot(this.instanceId); Path json = repository.getLayout().getModpackConfigurationFile(this.instanceId); if (repository.hasInstance(this.instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists"); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java index 5b6f61395d1..ed6258d17fe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -43,7 +43,8 @@ private LogExporter() { public static CompletableFuture exportLogs( Path zipFile, DefaultGameRepository repository, GameInstanceID instanceId, String logs, String launchScript, PathMatcher logMatcher) { - Path runDirectory = repository.resolveRunDirectory(instanceId); + DefaultGameInstance instance = repository.getSnapshot().findInstance(instanceId); + Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory(); Path baseDirectory = repository.getBaseDirectory(); List instances = new ArrayList<>(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index fd059631249..f741a6916b4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -142,7 +142,11 @@ private void analyzeCrashReport() { return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>()); }), Task.supplyAsync(() -> { - Path latestLog = repository.resolveRunDirectory(manifest.id()).resolve("logs/latest.log"); + DefaultGameInstance gameInstance = repository.getSnapshot().findInstance(manifest.id()); + Path runDirectory = gameInstance != null + ? gameInstance.getRunDirectory() + : repository.getBaseDirectory(); + Path latestLog = runDirectory.resolve("logs/latest.log"); if (!Files.isReadable(latestLog)) { return pair(new HashSet(), new HashSet()); } 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 6641431eb2e..92e89fe39c6 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -438,10 +438,10 @@ public void repositoryDirectoryFollowsGameDirectoryPath() throws ReflectiveOpera } } - /// Tests that new isolated installing instances resolve content directories under the version root before metadata is saved. + /// Tests that isolation settings written before install make a registered instance use the version root. @Test - public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@TempDir Path tempDirectory) - throws ReflectiveOperationException { + public void newIsolatedInstallingInstanceUsesVersionRootAfterPlaceholderSave(@TempDir Path tempDirectory) + throws Exception { GameSettingsPresetID defaultPresetId = GameSettingsPresetID.parse("game-settings-preset:123e4567-e89b-12d3-a456-426614174002"); GameSettings.Preset defaultPreset = new GameSettings.Preset(defaultPresetId); @@ -464,16 +464,17 @@ public void newIsolatedInstallingInstanceUsesVersionRootBeforeVersionExists(@Tem GameInstanceID id = new GameInstanceID("1.21.11-fabric"); assertFalse(repository.hasInstance(id)); - assertEquals(repository.getBaseDirectory(), repository.resolveRunDirectory(id)); + // Isolation is configured first; install then registers a placeholder instance. repository.applyDefaultIsolationSettingForNewInstance(id, true); + repository.saveAsync(new GameInstanceManifest(id)).run(); - assertEquals(repository.getLayout().getInstanceRoot(id), repository.resolveRunDirectory(id)); - assertEquals(repository.getLayout().getInstanceRoot(id).resolve("mods"), - repository.resolveRunDirectory(id).resolve("mods")); + HMCLGameInstance instance = repository.getInstance(id); + assertEquals(repository.getLayout().getInstanceRoot(id), instance.getRunDirectory()); + assertEquals(repository.getLayout().getInstanceRoot(id).resolve("mods"), instance.getModsDirectory()); assertTrue(repository.removeInstanceFromDisk(id)); - assertEquals(repository.getBaseDirectory(), repository.resolveRunDirectory(id)); + assertFalse(repository.hasInstance(id)); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java index e1ac56b747b..31397baf97a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -44,7 +44,10 @@ public DefaultDependencyManager getDependencyManager() { public Task buildAsync() { var hints = new ArrayList(); - Task libraryTask = Task.supplyAsync(() -> new GameInstanceManifest(name)); + // Register a placeholder instance first so install tasks can resolve run/mods directories + // through GameInstance instead of repository-level path helpers. + Task libraryTask = dependencyManager.getGameRepository() + .saveAsync(new GameInstanceManifest(name)); libraryTask = libraryTask.thenComposeAsync(libraryTaskHelper(gameVersion, "game", gameVersion)); hints.add(new Task.StagesHint("hmcl.install.game:" + gameVersion)); hints.add(new Task.StagesHint("hmcl.install.libraries")); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java index 0fe207888d7..1a918567e01 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java @@ -18,12 +18,15 @@ package org.jackhuang.hmcl.download.fabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -60,8 +63,17 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - dependencyManager.getGameRepository().resolveRunDirectory(manifest.id()).resolve("mods").resolve("fabric-api-" + remote.getVersion().version() + ".jar"), + modsDirectory(dependencyManager.getGameRepository(), manifest) + .resolve("fabric-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } + + private static Path modsDirectory(DefaultGameRepository repository, GameInstanceManifest manifest) { + DefaultGameInstance instance = repository.getSnapshot().findInstance(manifest.id()); + if (instance != null) { + return instance.getModsDirectory(); + } + return repository.getBaseDirectory().resolve("mods"); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java index dfa0013ff5a..2c558f5488f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java @@ -18,12 +18,15 @@ package org.jackhuang.hmcl.download.legacyfabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -55,8 +58,17 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - dependencyManager.getGameRepository().resolveRunDirectory(manifest.id()).resolve("mods").resolve("legacy-fabric-api-" + remote.getVersion().version() + ".jar"), + modsDirectory(dependencyManager.getGameRepository(), manifest) + .resolve("legacy-fabric-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } + + private static Path modsDirectory(DefaultGameRepository repository, GameInstanceManifest manifest) { + DefaultGameInstance instance = repository.getSnapshot().findInstance(manifest.id()); + if (instance != null) { + return instance.getModsDirectory(); + } + return repository.getBaseDirectory().resolve("mods"); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java index 26e4450a8a2..d06812c65ea 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java @@ -18,12 +18,15 @@ package org.jackhuang.hmcl.download.quilt; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; @@ -60,8 +63,17 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - dependencyManager.getGameRepository().resolveRunDirectory(manifest.id()).resolve("mods").resolve("quilt-api-" + remote.getVersion().version() + ".jar"), + modsDirectory(dependencyManager.getGameRepository(), manifest) + .resolve("quilt-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } + + private static Path modsDirectory(DefaultGameRepository repository, GameInstanceManifest manifest) { + DefaultGameInstance instance = repository.getSnapshot().findInstance(manifest.id()); + if (instance != null) { + return instance.getModsDirectory(); + } + return repository.getBaseDirectory().resolve("mods"); + } } 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 e615d0cc641..2ebb171e33e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -405,24 +405,6 @@ public DefaultGameInstance getInstance(GameInstanceID id) throws NoSuchGameInsta return getSnapshot().get(id); } - /// Resolves the run directory for an instance id. - /// - /// When the id is present in the current snapshot, this returns - /// [DefaultGameInstance#getRunDirectory]. Otherwise this returns the repository base directory - /// (shared run directory of the official layout). Install tasks and other id-based callers that - /// do not yet hold a [GameInstance] should use this method instead of a repository-level - /// `getRunDirectory` API. - /// - /// @param instanceId the instance id - /// @return the run directory - public Path resolveRunDirectory(GameInstanceID instanceId) { - DefaultGameInstance instance = findSnapshotInstance(instanceId); - if (instance != null) { - return instance.getRunDirectory(); - } - return getBaseDirectory(); - } - @Override public Path getInstanceJar(GameInstanceManifest manifest) { GameInstanceManifest resolved = this.resolve(manifest).launchManifest(); 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 20096137c3b..4414dab2e04 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java @@ -77,7 +77,7 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile this.iconUrl = iconUrl; this.repository = dependencyManager.getGameRepository(); - this.run = repository.resolveRunDirectory(instanceId); + this.run = repository.getLayout().getInstanceRoot(instanceId); Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) 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 df6fc459d0d..71366355656 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java @@ -59,7 +59,7 @@ public McbbsModpackLocalInstallTask(DefaultDependencyManager dependencyManager, this.manifest = manifest; this.instanceId = instanceId; this.repository = dependencyManager.getGameRepository(); - Path run = repository.resolveRunDirectory(instanceId); + Path run = repository.getLayout().getInstanceRoot(instanceId); Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) 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 6f1250be52f..0fb2196df01 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java @@ -62,7 +62,7 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF this.instanceId = instanceId; this.iconUrl = iconUrl; this.repository = dependencyManager.getGameRepository(); - this.run = repository.resolveRunDirectory(instanceId); + this.run = repository.getLayout().getInstanceRoot(instanceId); Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) 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 019b6756af1..aff974ea172 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 @@ -109,7 +109,7 @@ public boolean doPreExecute() { public void preExecute() throws Exception { // Stage #0: General Setup { - Path run = repository.resolveRunDirectory(instanceId); + Path run = repository.getLayout().getInstanceRoot(instanceId); Path json = repository.getLayout().getModpackConfigurationFile(instanceId); ModpackConfiguration config = null; 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 2ad581831b0..81730b1e859 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java @@ -52,7 +52,7 @@ public ServerModpackLocalInstallTask(DefaultDependencyManager dependencyManager, this.manifest = manifest; this.instanceId = instanceId; this.repository = dependencyManager.getGameRepository(); - Path run = repository.resolveRunDirectory(instanceId); + Path run = repository.getLayout().getInstanceRoot(instanceId); Path json = repository.getLayout().getModpackConfigurationFile(instanceId); if (repository.hasInstance(instanceId) && Files.notExists(json)) From d5b5eaa5a810fdad590499f668091d77fe6043ba Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 19:10:01 +0800 Subject: [PATCH 081/199] Pass explicit mods directory into Fabric and Quilt API install tasks Assisted-by: grok-build:grok-4.5 --- .../download/DefaultDependencyManager.java | 19 +++++++++++++- .../hmcl/download/RemoteVersion.java | 18 +++++++++++++ .../download/fabric/FabricAPIInstallTask.java | 25 +++++++++---------- .../fabric/FabricAPIRemoteVersion.java | 8 ++++-- .../LegacyFabricAPIInstallTask.java | 25 +++++++++---------- .../LegacyFabricAPIRemoteVersion.java | 8 ++++-- .../download/quilt/QuiltAPIInstallTask.java | 25 +++++++++---------- .../download/quilt/QuiltAPIRemoteVersion.java | 8 ++++-- 8 files changed, 90 insertions(+), 46 deletions(-) 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 cd0950298e0..5e1f9f9222c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -187,7 +187,7 @@ public Task installLibraryAsync(GameInstanceManifest baseV return removeLibraryAsync(baseVersion, libraryVersion.getLibraryId()) .thenComposeAsync(manifest -> { removedLibraryManifest.set(manifest); - return libraryVersion.getInstallTask(this, manifest); + return libraryVersion.getInstallTask(this, manifest, modsDirectoryFor(manifest)); }) .thenApplyAsync(patch -> { if (patch == null) { @@ -199,6 +199,23 @@ public Task installLibraryAsync(GameInstanceManifest baseV .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getLibraryId(), libraryVersion.getSelfVersion())); } + /// Resolves the mods directory for the instance identified by `manifest`. + /// + /// Prefer the registered [GameInstance] when present so isolation/run-directory policy is + /// honored. Falls back to the shared repository base directory when the instance is not yet + /// indexed (should be rare after [org.jackhuang.hmcl.download.DefaultGameBuilder] registers a + /// placeholder instance). + /// + /// @param manifest the install target manifest + /// @return the mods directory path + private Path modsDirectoryFor(GameInstanceManifest manifest) { + DefaultGameInstance instance = repository.getSnapshot().findInstance(manifest.id()); + if (instance != null) { + return instance.getModsDirectory(); + } + return repository.getBaseDirectory().resolve("mods"); + } + /// Creates a task that detects and runs a supported local library installer. /// /// @param oldVersion the manifest to which the installed patch will be added diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java index c3bf1da8e08..4bd8f79f057 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java @@ -23,6 +23,7 @@ import org.jackhuang.hmcl.util.ToStringBuilder; import org.jackhuang.hmcl.util.versioning.VersionNumber; +import java.nio.file.Path; import java.time.Instant; import java.util.List; import java.util.Objects; @@ -100,6 +101,23 @@ public Task getInstallTask(DefaultDependencyManager dependenc throw new UnsupportedOperationException(this + " cannot be installed yet"); } + /// Creates an install task with an explicit mods directory for libraries that download into the + /// instance run tree (for example Fabric/Quilt API). + /// + /// The default implementation ignores `modsDirectory` and delegates to + /// [#getInstallTask(DefaultDependencyManager, GameInstanceManifest)]. + /// + /// @param dependencyManager the dependency manager + /// @param baseVersion the manifest being installed into + /// @param modsDirectory the mods directory of the target instance run directory + /// @return the install task + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest baseVersion, + Path modsDirectory) { + return getInstallTask(dependencyManager, baseVersion); + } + @Override public boolean equals(Object obj) { return obj instanceof RemoteVersion && Objects.equals(selfVersion, ((RemoteVersion) obj).selfVersion); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java index 1a918567e01..03193f98704 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIInstallTask.java @@ -18,8 +18,6 @@ package org.jackhuang.hmcl.download.fabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.DefaultGameInstance; -import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -41,12 +39,22 @@ public final class FabricAPIInstallTask extends Task { private final DefaultDependencyManager dependencyManager; private final GameInstanceManifest manifest; private final FabricAPIRemoteVersion remote; + private final Path modsDirectory; private final List> dependencies = new ArrayList<>(1); - public FabricAPIInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, FabricAPIRemoteVersion remoteVersion) { + /// @param dependencyManager the dependency manager + /// @param manifest the manifest being installed into + /// @param remoteVersion the Fabric API remote version + /// @param modsDirectory the target mods directory (must already be resolved by the caller) + public FabricAPIInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + FabricAPIRemoteVersion remoteVersion, + Path modsDirectory) { this.dependencyManager = dependencyManager; this.manifest = manifest; this.remote = remoteVersion; + this.modsDirectory = modsDirectory; } @Override @@ -63,17 +71,8 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - modsDirectory(dependencyManager.getGameRepository(), manifest) - .resolve("fabric-api-" + remote.getVersion().version() + ".jar"), + modsDirectory.resolve("fabric-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } - - private static Path modsDirectory(DefaultGameRepository repository, GameInstanceManifest manifest) { - DefaultGameInstance instance = repository.getSnapshot().findInstance(manifest.id()); - if (instance != null) { - return instance.getModsDirectory(); - } - return repository.getBaseDirectory().resolve("mods"); - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIRemoteVersion.java index db2e955ccc3..7458b3f964d 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 @@ -25,6 +25,7 @@ import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.task.Task; +import java.nio.file.Path; import java.time.Instant; import java.util.List; @@ -56,8 +57,11 @@ public RemoteAddon.Version getVersion() { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new FabricAPIInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest baseVersion, + Path modsDirectory) { + return new FabricAPIInstallTask(dependencyManager, baseVersion, this, modsDirectory); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java index 2c558f5488f..07818feee9b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIInstallTask.java @@ -18,8 +18,6 @@ package org.jackhuang.hmcl.download.legacyfabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.DefaultGameInstance; -import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -36,12 +34,22 @@ public final class LegacyFabricAPIInstallTask extends Task { private final DefaultDependencyManager dependencyManager; private final GameInstanceManifest manifest; private final LegacyFabricAPIRemoteVersion remote; + private final Path modsDirectory; private final List> dependencies = new ArrayList<>(1); - public LegacyFabricAPIInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, LegacyFabricAPIRemoteVersion remoteVersion) { + /// @param dependencyManager the dependency manager + /// @param manifest the manifest being installed into + /// @param remoteVersion the Legacy Fabric API remote version + /// @param modsDirectory the target mods directory (must already be resolved by the caller) + public LegacyFabricAPIInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + LegacyFabricAPIRemoteVersion remoteVersion, + Path modsDirectory) { this.dependencyManager = dependencyManager; this.manifest = manifest; this.remote = remoteVersion; + this.modsDirectory = modsDirectory; } @Override @@ -58,17 +66,8 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - modsDirectory(dependencyManager.getGameRepository(), manifest) - .resolve("legacy-fabric-api-" + remote.getVersion().version() + ".jar"), + modsDirectory.resolve("legacy-fabric-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } - - private static Path modsDirectory(DefaultGameRepository repository, GameInstanceManifest manifest) { - DefaultGameInstance instance = repository.getSnapshot().findInstance(manifest.id()); - if (instance != null) { - return instance.getModsDirectory(); - } - return repository.getBaseDirectory().resolve("mods"); - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java index fbf157f14e2..cb7f700291d 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 @@ -25,6 +25,7 @@ import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.task.Task; +import java.nio.file.Path; import java.time.Instant; import java.util.List; @@ -56,8 +57,11 @@ public RemoteAddon.Version getVersion() { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new LegacyFabricAPIInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest baseVersion, + Path modsDirectory) { + return new LegacyFabricAPIInstallTask(dependencyManager, baseVersion, this, modsDirectory); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java index d06812c65ea..e78a65ca592 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIInstallTask.java @@ -18,8 +18,6 @@ package org.jackhuang.hmcl.download.quilt; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.DefaultGameInstance; -import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -41,12 +39,22 @@ public final class QuiltAPIInstallTask extends Task { private final DefaultDependencyManager dependencyManager; private final GameInstanceManifest manifest; private final QuiltAPIRemoteVersion remote; + private final Path modsDirectory; private final List> dependencies = new ArrayList<>(1); - public QuiltAPIInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, QuiltAPIRemoteVersion remoteVersion) { + /// @param dependencyManager the dependency manager + /// @param manifest the manifest being installed into + /// @param remoteVersion the Quilt API remote version + /// @param modsDirectory the target mods directory (must already be resolved by the caller) + public QuiltAPIInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + QuiltAPIRemoteVersion remoteVersion, + Path modsDirectory) { this.dependencyManager = dependencyManager; this.manifest = manifest; this.remote = remoteVersion; + this.modsDirectory = modsDirectory; } @Override @@ -63,17 +71,8 @@ public boolean isRelyingOnDependencies() { public void execute() throws IOException { dependencies.add(new FileDownloadTask( remote.getVersion().file().url(), - modsDirectory(dependencyManager.getGameRepository(), manifest) - .resolve("quilt-api-" + remote.getVersion().version() + ".jar"), + modsDirectory.resolve("quilt-api-" + remote.getVersion().version() + ".jar"), remote.getVersion().file().getIntegrityCheck()) ); } - - private static Path modsDirectory(DefaultGameRepository repository, GameInstanceManifest manifest) { - DefaultGameInstance instance = repository.getSnapshot().findInstance(manifest.id()); - if (instance != null) { - return instance.getModsDirectory(); - } - return repository.getBaseDirectory().resolve("mods"); - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIRemoteVersion.java index 2ccc82a7c24..ae8076499b6 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 @@ -25,6 +25,7 @@ import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.task.Task; +import java.nio.file.Path; import java.time.Instant; import java.util.List; @@ -56,8 +57,11 @@ public RemoteAddon.Version getVersion() { } @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new QuiltAPIInstallTask(dependencyManager, baseVersion, this); + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest baseVersion, + Path modsDirectory) { + return new QuiltAPIInstallTask(dependencyManager, baseVersion, this, modsDirectory); } @Override From b01a00b30224f3d56945c4fbc9f43461cfec85ac Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 19:41:59 +0800 Subject: [PATCH 082/199] feat(BundledModpackBootstrap): add automatic modpack installation for empty repositories --- .../hmcl/game/BundledModpackBootstrap.java | 101 ++++++++++++++++++ .../jackhuang/hmcl/game/ModpackHelper.java | 2 +- .../org/jackhuang/hmcl/ui/Controllers.java | 6 ++ .../org/jackhuang/hmcl/ui/main/RootPage.java | 45 -------- 4 files changed, 108 insertions(+), 46 deletions(-) create mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/game/BundledModpackBootstrap.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/BundledModpackBootstrap.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/BundledModpackBootstrap.java new file mode 100644 index 00000000000..e41a18bfe88 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/BundledModpackBootstrap.java @@ -0,0 +1,101 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.setting.GameDirectoryManager; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.task.TaskExecutor; +import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Installs a modpack bundled next to the launcher when the selected repository is empty. +/// +/// Looks for `modpack.zip` or `modpack.mrpack` under [Metadata#CURRENT_DIRECTORY]. This is a +/// startup product feature (portable / first-run bundle), not UI page logic. +@NotNullByDefault +public final class BundledModpackBootstrap { + + private static final AtomicBoolean attempted = new AtomicBoolean(); + + private BundledModpackBootstrap() { + } + + /// Returns the bundled modpack file under the process working directory, if present. + /// + /// Prefers `modpack.zip` over `modpack.mrpack` when both exist. + /// + /// @return the modpack path, or `null` when neither file exists + public static @Nullable Path findBundledModpackFile() { + Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip"); + if (Files.isRegularFile(zipModpack)) { + return zipModpack; + } + Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack"); + if (Files.isRegularFile(mrpackModpack)) { + return mrpackModpack; + } + return null; + } + + /// Schedules a one-shot attempt after the selected repository finishes a full refresh. + /// + /// When the repository has no instances and a bundled modpack file exists, builds an install + /// [TaskExecutor] and passes it to `presentAndStart` on the JavaFX thread. The consumer should + /// show progress UI (if any) and call [TaskExecutor#start]. + /// + /// @param presentAndStart presents and starts the install executor; must not be null + public static void scheduleAfterSelectedRepositoryLoaded(Consumer presentAndStart) { + GameDirectoryManager.registerVersionsListener(repository -> + tryInstall(repository, presentAndStart)); + } + + /// Attempts a one-shot bundled modpack install for the given repository. + private static void tryInstall(HMCLGameRepository repository, Consumer presentAndStart) { + if (!attempted.compareAndSet(false, true)) { + return; + } + if (repository.getInstanceCount() != 0) { + return; + } + + @Nullable Path modpackFile = findBundledModpackFile(); + if (modpackFile == null) { + return; + } + + LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install"); + + Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile)) + .thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding)) + .thenApplyAsync(modpack -> ModpackHelper + .getInstallTask(repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null) + .executor()) + .thenAcceptAsync(Schedulers.javafx(), presentAndStart::accept) + .start(); + } +} 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 0ba24ade9d4..5171b312588 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java @@ -196,7 +196,7 @@ public static Task getInstallManuallyCreatedModpackTask(Path zipFile, String }); } - public static Task getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, String iconUrl) { + public static Task getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) { repository.ensureIsolatedRunningDirectory(instanceId); ExceptionalRunnable success = () -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 53996f7bd28..e581150e9ec 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -33,6 +33,7 @@ import javafx.util.Duration; import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.Metadata; +import org.jackhuang.hmcl.game.BundledModpackBootstrap; import org.jackhuang.hmcl.game.LauncherHelper; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; @@ -370,6 +371,11 @@ public static void initialize(Stage stage) { }, updateShowTips); }, updateShowTips); } + + BundledModpackBootstrap.scheduleAfterSelectedRepositoryLoaded(executor -> { + Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL); + executor.start(); + }); } public static void dialog(Region content) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java index fdbd112f640..f52c6a2fea0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/RootPage.java @@ -20,15 +20,11 @@ import com.jfoenix.controls.JFXPopup; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.scene.layout.Region; -import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.setting.Accounts; import org.jackhuang.hmcl.setting.GameDirectoryManager; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.terracotta.TerracottaMetadata; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -50,17 +46,13 @@ import org.jackhuang.hmcl.upgrade.UpdateChecker; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.TaskCancellationAction; -import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.*; import org.jetbrains.annotations.Nullable; -import java.nio.file.Files; import java.nio.file.Path; import java.util.Locale; -import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -68,8 +60,6 @@ public class RootPage extends DecoratorAnimatedPage implements DecoratorPage { private MainPage mainPage = null; public RootPage() { - GameDirectoryManager.registerVersionsListener(this::onRefreshedVersions); - getStyleClass().remove("gray-background"); getLeft().getStyleClass().add("gray-background"); } @@ -231,39 +221,4 @@ public void showGameListPopupMenu(Region gameListItem) { } } - private boolean checkedModpack = false; - - private void onRefreshedVersions(HMCLGameRepository repository) { - runInFX(() -> { - if (!checkedModpack) { - checkedModpack = true; - - if (repository.getInstanceCount() == 0) { - Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip"); - Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack"); - - Path modpackFile; - if (Files.exists(zipModpack)) { - modpackFile = zipModpack; - } else if (Files.exists(mrpackModpack)) { - modpackFile = mrpackModpack; - } else { - modpackFile = null; - } - - if (modpackFile != null) { - Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile)) - .thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding)) - .thenApplyAsync(modpack -> ModpackHelper - .getInstallTask(repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null) - .executor()) - .thenAcceptAsync(Schedulers.javafx(), executor -> { - Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL); - executor.start(); - }).start(); - } - } - } - }); - } } From 47cebf0494f631bf2ec0db721499dbbb71870121 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 20:19:05 +0800 Subject: [PATCH 083/199] feat(Launcher): implement automatic installation of bundled modpack on repository selection --- .../java/org/jackhuang/hmcl/Metadata.java | 19 ++++ .../hmcl/game/BundledModpackBootstrap.java | 101 ------------------ .../jackhuang/hmcl/setting/LauncherState.java | 21 ++++ .../org/jackhuang/hmcl/ui/Controllers.java | 77 ++++++++++++- .../hmcl/setting/LauncherStateTest.java | 11 ++ 5 files changed, 123 insertions(+), 106 deletions(-) delete mode 100644 HMCL/src/main/java/org/jackhuang/hmcl/game/BundledModpackBootstrap.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java index c81379dbc5f..799edff50e4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java @@ -23,6 +23,7 @@ import org.jackhuang.hmcl.util.platform.OperatingSystem; import org.jetbrains.annotations.Nullable; +import java.nio.file.Files; import java.nio.file.Path; import java.util.EnumSet; @@ -133,4 +134,22 @@ else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) return null; } } + + /// Returns the bundled modpack file under the process working directory, if present. + /// + /// Prefers `modpack.zip` over `modpack.mrpack` when both exist. + /// + /// @return the modpack path, or `null` when neither file exists + public static @Nullable Path findBundledModpackFile() { + Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip"); + if (Files.isRegularFile(zipModpack)) { + return zipModpack; + } + Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack"); + if (Files.isRegularFile(mrpackModpack)) { + return mrpackModpack; + } + return null; + } + } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/BundledModpackBootstrap.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/BundledModpackBootstrap.java deleted file mode 100644 index e41a18bfe88..00000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/BundledModpackBootstrap.java +++ /dev/null @@ -1,101 +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.game; - -import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.setting.GameDirectoryManager; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.task.TaskExecutor; -import org.jackhuang.hmcl.util.io.CompressingUtils; -import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.function.Consumer; - -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -/// Installs a modpack bundled next to the launcher when the selected repository is empty. -/// -/// Looks for `modpack.zip` or `modpack.mrpack` under [Metadata#CURRENT_DIRECTORY]. This is a -/// startup product feature (portable / first-run bundle), not UI page logic. -@NotNullByDefault -public final class BundledModpackBootstrap { - - private static final AtomicBoolean attempted = new AtomicBoolean(); - - private BundledModpackBootstrap() { - } - - /// Returns the bundled modpack file under the process working directory, if present. - /// - /// Prefers `modpack.zip` over `modpack.mrpack` when both exist. - /// - /// @return the modpack path, or `null` when neither file exists - public static @Nullable Path findBundledModpackFile() { - Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip"); - if (Files.isRegularFile(zipModpack)) { - return zipModpack; - } - Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack"); - if (Files.isRegularFile(mrpackModpack)) { - return mrpackModpack; - } - return null; - } - - /// Schedules a one-shot attempt after the selected repository finishes a full refresh. - /// - /// When the repository has no instances and a bundled modpack file exists, builds an install - /// [TaskExecutor] and passes it to `presentAndStart` on the JavaFX thread. The consumer should - /// show progress UI (if any) and call [TaskExecutor#start]. - /// - /// @param presentAndStart presents and starts the install executor; must not be null - public static void scheduleAfterSelectedRepositoryLoaded(Consumer presentAndStart) { - GameDirectoryManager.registerVersionsListener(repository -> - tryInstall(repository, presentAndStart)); - } - - /// Attempts a one-shot bundled modpack install for the given repository. - private static void tryInstall(HMCLGameRepository repository, Consumer presentAndStart) { - if (!attempted.compareAndSet(false, true)) { - return; - } - if (repository.getInstanceCount() != 0) { - return; - } - - @Nullable Path modpackFile = findBundledModpackFile(); - if (modpackFile == null) { - return; - } - - LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install"); - - Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile)) - .thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding)) - .thenApplyAsync(modpack -> ModpackHelper - .getInstallTask(repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null) - .executor()) - .thenAcceptAsync(Schedulers.javafx(), presentAndStart::accept) - .start(); - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java index ba5af8a027c..4992dbd4d8b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java @@ -20,8 +20,10 @@ import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; import javafx.beans.Observable; +import javafx.beans.property.BooleanProperty; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; +import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; @@ -208,6 +210,25 @@ public void setPromptedVersion(@Nullable String promptedVersion) { this.promptedVersion.set(promptedVersion); } + /// Whether the launcher has already offered automatic install of a cwd-bundled modpack. + @SerializedName("bundledModpackInstalled") + private final BooleanProperty bundledModpackInstalled = new SimpleBooleanProperty(); + + /// Returns whether a bundled modpack has already been offered for automatic install. + public boolean isBundledModpackInstalled() { + return bundledModpackInstalled.get(); + } + + /// Returns the bundled-modpack-installed property. + public BooleanProperty bundledModpackInstalledProperty() { + return bundledModpackInstalled; + } + + /// Sets whether a bundled modpack has already been offered for automatic install. + public void setBundledModpackInstalled(boolean bundledModpackInstalled) { + this.bundledModpackInstalled.set(bundledModpackInstalled); + } + /// Tip markers that prevent repeated prompts. @SerializedName("shownTips") private final ObservableMap shownTips = FXCollections.observableHashMap(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index e581150e9ec..82679c0fed9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -24,6 +24,7 @@ import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.application.Platform; +import javafx.beans.value.ChangeListener; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.ButtonBase; @@ -33,11 +34,14 @@ import javafx.util.Duration; import org.jackhuang.hmcl.Launcher; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.game.BundledModpackBootstrap; +import org.jackhuang.hmcl.game.GameInstanceID; +import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.LauncherHelper; +import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.setting.*; +import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.task.TaskExecutor; import org.jackhuang.hmcl.ui.account.AccountListPage; @@ -57,6 +61,7 @@ import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.SupportedLocale; +import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; @@ -69,6 +74,7 @@ import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; @@ -372,10 +378,71 @@ public static void initialize(Stage stage) { }, updateShowTips); } - BundledModpackBootstrap.scheduleAfterSelectedRepositoryLoaded(executor -> { - Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL); - executor.start(); - }); + scheduleBundledModpackInstall(); + } + + /// Guards against concurrent schedule attempts within one process. + private static final AtomicBoolean bundledModpackAttempted = new AtomicBoolean(); + + /// Refresh listener attached to the currently observed selected repository. + private static @Nullable ChangeListener bundledModpackRefreshListener; + + /// Offers automatic install of a cwd-bundled modpack at most once per launcher state. + /// + /// Listens to the selected repository's [HMCLGameRepository#refreshCountProperty] (and runs + /// immediately when that repository is already loaded). Whether to install is decided by + /// [LauncherState#isBundledModpackInstalled], not by whether the repository is empty. + private static void scheduleBundledModpackInstall() { + ChangeListener onSelectedRepository = (observable, oldRepository, newRepository) -> { + if (oldRepository != null && bundledModpackRefreshListener != null) { + oldRepository.refreshCountProperty().removeListener(bundledModpackRefreshListener); + bundledModpackRefreshListener = null; + } + if (newRepository == null) { + return; + } + bundledModpackRefreshListener = (obs, oldCount, newCount) -> + tryInstallBundledModpack(newRepository); + newRepository.refreshCountProperty().addListener(bundledModpackRefreshListener); + if (newRepository.isLoaded()) { + tryInstallBundledModpack(newRepository); + } + }; + + GameDirectoryManager.selectedRepositoryProperty().addListener(onSelectedRepository); + onSelectedRepository.changed( + GameDirectoryManager.selectedRepositoryProperty(), + null, + GameDirectoryManager.getSelectedRepository()); + } + + private static void tryInstallBundledModpack(HMCLGameRepository repository) { + if (!bundledModpackAttempted.compareAndSet(false, true)) { + return; + } + if (state().isBundledModpackInstalled()) { + return; + } + + @Nullable Path modpackFile = Metadata.findBundledModpackFile(); + if (modpackFile == null) { + return; + } + + LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install"); + + Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile)) + .thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding)) + .thenApplyAsync(modpack -> ModpackHelper + .getInstallTask(repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null) + .executor()) + .thenAcceptAsync(Schedulers.javafx(), executor -> { + // Record before presentation so a cancelled dialog does not re-prompt every launch. + state().setBundledModpackInstalled(true); + Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL); + executor.start(); + }) + .start(); } public static void dialog(Region content) { diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java index 194ec10c6c5..3cb9913b66d 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java @@ -56,9 +56,20 @@ public void identifiesDeferredWindowGeometryFields() { assertFalse(state.shouldSaveImmediately(state.heightProperty())); assertTrue(state.shouldSaveImmediately(state.schemaProperty())); assertTrue(state.shouldSaveImmediately(state.promptedVersionProperty())); + assertTrue(state.shouldSaveImmediately(state.bundledModpackInstalledProperty())); assertTrue(state.shouldSaveImmediately(state.getShownTips())); } + /// Tests that the bundled-modpack-installed flag round-trips through the state store fields. + @Test + public void storesBundledModpackInstalled() { + LauncherState state = new LauncherState(); + assertFalse(state.isBundledModpackInstalled()); + + state.setBundledModpackInstalled(true); + assertTrue(state.isBundledModpackInstalled()); + } + /// Tests extracting runtime state fields from a legacy config object. @Test public void extractsLauncherStateFromLegacyConfigJson() { From d8101bd6ebeb5e5d7c6b25cf8c4fa226523a67f8 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 20:43:47 +0800 Subject: [PATCH 084/199] feat(BundledModpack): enhance automatic installation process and streamline modpack handling --- .../java/org/jackhuang/hmcl/Metadata.java | 20 ++++-- .../jackhuang/hmcl/setting/LauncherState.java | 21 ------ .../org/jackhuang/hmcl/ui/Controllers.java | 66 ++++++++++++------- .../hmcl/ui/export/ExportWizardProvider.java | 14 +++- .../hmcl/setting/LauncherStateTest.java | 11 ---- 5 files changed, 68 insertions(+), 64 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java index 799edff50e4..af18649979e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java @@ -135,17 +135,27 @@ else if (OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) } } - /// Returns the bundled modpack file under the process working directory, if present. + /// Directory under [HMCL_LOCAL_HOME] that holds a launcher-bundled modpack for automatic install. + public static final String BUNDLED_MODPACK_DIRECTORY_NAME = "modpack"; + + /// Returns the directory for a launcher-bundled modpack (`[HMCL_LOCAL_HOME]/modpack`). + public static Path getBundledModpackDirectory() { + return HMCL_LOCAL_HOME.resolve(BUNDLED_MODPACK_DIRECTORY_NAME); + } + + /// Returns the bundled modpack package under [getBundledModpackDirectory], if present. /// - /// Prefers `modpack.zip` over `modpack.mrpack` when both exist. + /// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the + /// signal to offer automatic install; the file is removed when install starts. /// - /// @return the modpack path, or `null` when neither file exists + /// @return the modpack path, or `null` when no package is present public static @Nullable Path findBundledModpackFile() { - Path zipModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.zip"); + Path directory = getBundledModpackDirectory(); + Path zipModpack = directory.resolve("modpack.zip"); if (Files.isRegularFile(zipModpack)) { return zipModpack; } - Path mrpackModpack = Metadata.CURRENT_DIRECTORY.resolve("modpack.mrpack"); + Path mrpackModpack = directory.resolve("modpack.mrpack"); if (Files.isRegularFile(mrpackModpack)) { return mrpackModpack; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java index 4992dbd4d8b..ba5af8a027c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/LauncherState.java @@ -20,10 +20,8 @@ import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; import javafx.beans.Observable; -import javafx.beans.property.BooleanProperty; import javafx.beans.property.DoubleProperty; import javafx.beans.property.ObjectProperty; -import javafx.beans.property.SimpleBooleanProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.property.SimpleObjectProperty; import javafx.beans.property.SimpleStringProperty; @@ -210,25 +208,6 @@ public void setPromptedVersion(@Nullable String promptedVersion) { this.promptedVersion.set(promptedVersion); } - /// Whether the launcher has already offered automatic install of a cwd-bundled modpack. - @SerializedName("bundledModpackInstalled") - private final BooleanProperty bundledModpackInstalled = new SimpleBooleanProperty(); - - /// Returns whether a bundled modpack has already been offered for automatic install. - public boolean isBundledModpackInstalled() { - return bundledModpackInstalled.get(); - } - - /// Returns the bundled-modpack-installed property. - public BooleanProperty bundledModpackInstalledProperty() { - return bundledModpackInstalled; - } - - /// Sets whether a bundled modpack has already been offered for automatic install. - public void setBundledModpackInstalled(boolean bundledModpackInstalled) { - this.bundledModpackInstalled.set(bundledModpackInstalled); - } - /// Tip markers that prevent repeated prompts. @SerializedName("shownTips") private final ObservableMap shownTips = FXCollections.observableHashMap(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 82679c0fed9..13e529a1874 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -69,12 +69,12 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; import java.util.List; import java.util.Objects; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicBoolean; import static org.jackhuang.hmcl.setting.SettingsManager.settings; import static org.jackhuang.hmcl.setting.SettingsManager.getAuthlibInjectorServers; @@ -381,17 +381,14 @@ public static void initialize(Stage stage) { scheduleBundledModpackInstall(); } - /// Guards against concurrent schedule attempts within one process. - private static final AtomicBoolean bundledModpackAttempted = new AtomicBoolean(); - /// Refresh listener attached to the currently observed selected repository. private static @Nullable ChangeListener bundledModpackRefreshListener; - /// Offers automatic install of a cwd-bundled modpack at most once per launcher state. + /// Offers automatic install when a package exists under `.hmcl/modpack/`. /// /// Listens to the selected repository's [HMCLGameRepository#refreshCountProperty] (and runs - /// immediately when that repository is already loaded). Whether to install is decided by - /// [LauncherState#isBundledModpackInstalled], not by whether the repository is empty. + /// immediately when that repository is already loaded). The package file itself is the install + /// signal; it is deleted when install starts so later refreshes do not re-prompt. private static void scheduleBundledModpackInstall() { ChangeListener onSelectedRepository = (observable, oldRepository, newRepository) -> { if (oldRepository != null && bundledModpackRefreshListener != null) { @@ -417,32 +414,51 @@ private static void scheduleBundledModpackInstall() { } private static void tryInstallBundledModpack(HMCLGameRepository repository) { - if (!bundledModpackAttempted.compareAndSet(false, true)) { - return; - } - if (state().isBundledModpackInstalled()) { + @Nullable Path modpackFile = Metadata.findBundledModpackFile(); + if (modpackFile == null) { return; } - @Nullable Path modpackFile = Metadata.findBundledModpackFile(); - if (modpackFile == null) { + // Move the package out of .hmcl/modpack/ so presence of modpack.zip|mrpack is no longer a signal. + final Path installSource; + try { + String suffix = modpackFile.getFileName().toString().endsWith(".mrpack") ? ".mrpack" : ".zip"; + installSource = Files.createTempFile("hmcl-bundled-modpack", suffix); + Files.move(modpackFile, installSource, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + LOG.warning("Failed to claim bundled modpack package: " + modpackFile, e); return; } LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install"); - Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(modpackFile)) - .thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(modpackFile, encoding)) - .thenApplyAsync(modpack -> ModpackHelper - .getInstallTask(repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null) - .executor()) - .thenAcceptAsync(Schedulers.javafx(), executor -> { - // Record before presentation so a cancelled dialog does not re-prompt every launch. - state().setBundledModpackInstalled(true); - Controllers.taskDialog(executor, i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL); - executor.start(); - }) - .start(); + Controllers.taskDialog( + Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(installSource)) + .thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(installSource, encoding)) + .thenComposeAsync(modpack -> { + Task installTask = ModpackHelper.getInstallTask( + repository, installSource, new GameInstanceID(modpack.getName()), modpack, null); + // Keep installSource until the install task finishes reading the package. + installTask.whenComplete(exception -> { + try { + Files.deleteIfExists(installSource); + } catch (IOException e) { + LOG.warning("Failed to delete temporary bundled modpack: " + installSource, e); + } + }); + return installTask; + }) + .whenComplete(Schedulers.javafx(), (ignored, exception) -> { + if (exception != null) { + LOG.warning("Failed to prepare bundled modpack install", exception); + try { + Files.deleteIfExists(installSource); + } catch (IOException e) { + LOG.warning("Failed to delete temporary bundled modpack: " + installSource, e); + } + } + }), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL + ); } public static void dialog(Region content) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java index 6f82e466736..e930fc641a0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/export/ExportWizardProvider.java @@ -134,9 +134,12 @@ public void execute() throws Exception { zip.putTextFile( JsonUtils.GSON.toJson(exportedServers, AuthlibInjectorServerList.class), ".hmcl/config/authlib-injector-servers.json"); - zip.putFile(tempModpack, ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType) + + // Bundled package under .hmcl/modpack/ (not the process workdir). + String packageName = ModpackTypeSelectionPage.MODPACK_TYPE_MODRINTH.equals(modpackType) ? "modpack.mrpack" - : "modpack.zip"); + : "modpack.zip"; + zip.putFile(tempModpack, ".hmcl/" + Metadata.BUNDLED_MODPACK_DIRECTORY_NAME + "/" + packageName); for (String extension : FontManager.FONT_EXTENSIONS) { String fileName = "font." + extension; @@ -148,6 +151,13 @@ public void execute() throws Exception { } zip.putFile(launcherJar, launcherJar.getFileName().toString()); + } finally { + if (tempModpack != null) { + try { + Files.deleteIfExists(tempModpack); + } catch (Exception ignored) { + } + } } } }; diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java index 3cb9913b66d..194ec10c6c5 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/LauncherStateTest.java @@ -56,20 +56,9 @@ public void identifiesDeferredWindowGeometryFields() { assertFalse(state.shouldSaveImmediately(state.heightProperty())); assertTrue(state.shouldSaveImmediately(state.schemaProperty())); assertTrue(state.shouldSaveImmediately(state.promptedVersionProperty())); - assertTrue(state.shouldSaveImmediately(state.bundledModpackInstalledProperty())); assertTrue(state.shouldSaveImmediately(state.getShownTips())); } - /// Tests that the bundled-modpack-installed flag round-trips through the state store fields. - @Test - public void storesBundledModpackInstalled() { - LauncherState state = new LauncherState(); - assertFalse(state.isBundledModpackInstalled()); - - state.setBundledModpackInstalled(true); - assertTrue(state.isBundledModpackInstalled()); - } - /// Tests extracting runtime state fields from a legacy config object. @Test public void extractsLauncherStateFromLegacyConfigJson() { From d17631a9f760795ab07d388445c60fe35f7a1855 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 20:54:06 +0800 Subject: [PATCH 085/199] feat(Controllers): streamline bundled modpack installation process and improve error handling --- .../java/org/jackhuang/hmcl/Metadata.java | 2 +- .../org/jackhuang/hmcl/ui/Controllers.java | 81 +++++-------------- 2 files changed, 19 insertions(+), 64 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java index af18649979e..bc703be99f6 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/Metadata.java @@ -146,7 +146,7 @@ public static Path getBundledModpackDirectory() { /// Returns the bundled modpack package under [getBundledModpackDirectory], if present. /// /// Prefers `modpack.zip` over `modpack.mrpack` when both exist. Presence of the package is the - /// signal to offer automatic install; the file is removed when install starts. + /// signal to offer automatic install; the file is removed after a successful install. /// /// @return the modpack path, or `null` when no package is present public static @Nullable Path findBundledModpackFile() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 13e529a1874..433d5f1358f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -24,7 +24,6 @@ import javafx.animation.KeyValue; import javafx.animation.Timeline; import javafx.application.Platform; -import javafx.beans.value.ChangeListener; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.ButtonBase; @@ -40,6 +39,7 @@ import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; +import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.setting.*; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -69,6 +69,7 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.nio.charset.Charset; import java.nio.file.Files; import java.nio.file.Path; import java.time.LocalDate; @@ -378,84 +379,38 @@ public static void initialize(Stage stage) { }, updateShowTips); } - scheduleBundledModpackInstall(); + tryInstallBundledModpack(GameDirectoryManager.getSelectedRepository()); } - /// Refresh listener attached to the currently observed selected repository. - private static @Nullable ChangeListener bundledModpackRefreshListener; - /// Offers automatic install when a package exists under `.hmcl/modpack/`. /// - /// Listens to the selected repository's [HMCLGameRepository#refreshCountProperty] (and runs - /// immediately when that repository is already loaded). The package file itself is the install - /// signal; it is deleted when install starts so later refreshes do not re-prompt. - private static void scheduleBundledModpackInstall() { - ChangeListener onSelectedRepository = (observable, oldRepository, newRepository) -> { - if (oldRepository != null && bundledModpackRefreshListener != null) { - oldRepository.refreshCountProperty().removeListener(bundledModpackRefreshListener); - bundledModpackRefreshListener = null; - } - if (newRepository == null) { - return; - } - bundledModpackRefreshListener = (obs, oldCount, newCount) -> - tryInstallBundledModpack(newRepository); - newRepository.refreshCountProperty().addListener(bundledModpackRefreshListener); - if (newRepository.isLoaded()) { - tryInstallBundledModpack(newRepository); - } - }; - - GameDirectoryManager.selectedRepositoryProperty().addListener(onSelectedRepository); - onSelectedRepository.changed( - GameDirectoryManager.selectedRepositoryProperty(), - null, - GameDirectoryManager.getSelectedRepository()); - } - + /// Called from [Controllers#initialize] after the UI is ready. Install does not wait for repository + /// refresh: instance paths come from the selected repository layout. The package file itself is the + /// install signal; it is deleted after a successful install so later startups do not re-prompt. private static void tryInstallBundledModpack(HMCLGameRepository repository) { @Nullable Path modpackFile = Metadata.findBundledModpackFile(); if (modpackFile == null) { return; } - // Move the package out of .hmcl/modpack/ so presence of modpack.zip|mrpack is no longer a signal. - final Path installSource; - try { - String suffix = modpackFile.getFileName().toString().endsWith(".mrpack") ? ".mrpack" : ".zip"; - installSource = Files.createTempFile("hmcl-bundled-modpack", suffix); - Files.move(modpackFile, installSource, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - } catch (IOException e) { - LOG.warning("Failed to claim bundled modpack package: " + modpackFile, e); - return; - } - LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install"); Controllers.taskDialog( - Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(installSource)) - .thenApplyAsync(encoding -> ModpackHelper.readModpackManifest(installSource, encoding)) - .thenComposeAsync(modpack -> { - Task installTask = ModpackHelper.getInstallTask( - repository, installSource, new GameInstanceID(modpack.getName()), modpack, null); - // Keep installSource until the install task finishes reading the package. - installTask.whenComplete(exception -> { - try { - Files.deleteIfExists(installSource); - } catch (IOException e) { - LOG.warning("Failed to delete temporary bundled modpack: " + installSource, e); - } - }); - return installTask; + Task.composeAsync(() -> { + Charset encoding = CompressingUtils.findSuitableEncoding(modpackFile); + Modpack modpack = ModpackHelper.readModpackManifest(modpackFile, encoding); + return ModpackHelper.getInstallTask( + repository, modpackFile, new GameInstanceID(modpack.getName()), modpack, null); }) .whenComplete(Schedulers.javafx(), (ignored, exception) -> { if (exception != null) { - LOG.warning("Failed to prepare bundled modpack install", exception); - try { - Files.deleteIfExists(installSource); - } catch (IOException e) { - LOG.warning("Failed to delete temporary bundled modpack: " + installSource, e); - } + LOG.warning("Failed to install bundled modpack", exception); + return; + } + try { + Files.deleteIfExists(modpackFile); + } catch (IOException e) { + LOG.warning("Failed to delete bundled modpack: " + modpackFile, e); } }), i18n("modpack.installing"), TaskCancellationAction.NO_CANCEL ); From 2d111e20ecc88949879493c7188bf3ea0a1ec529 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 20:56:46 +0800 Subject: [PATCH 086/199] feat(Controllers): improve asynchronous task handling for bundled modpack installation --- HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java index 433d5f1358f..cf2a49ab957 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/Controllers.java @@ -396,7 +396,7 @@ private static void tryInstallBundledModpack(HMCLGameRepository repository) { LOG.info("Found bundled modpack at " + modpackFile + "; starting automatic install"); Controllers.taskDialog( - Task.composeAsync(() -> { + Task.composeAsync(Schedulers.io(), () -> { Charset encoding = CompressingUtils.findSuitableEncoding(modpackFile); Modpack modpack = ModpackHelper.readModpackManifest(modpackFile, encoding); return ModpackHelper.getInstallTask( From feaa9a5f913102a70b476b1e911aa6b048b0b431 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 21:13:24 +0800 Subject: [PATCH 087/199] feat(Repository): refactor refresh handling to utilize snapshots and remove unused refresh count --- .../hmcl/setting/GameDirectoryManager.java | 20 +++++---- .../hmcl/ui/instances/GameInstancePage.java | 13 +++--- .../hmcl/game/DefaultGameRepository.java | 41 ++++--------------- 3 files changed, 26 insertions(+), 48 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java index c27b6485b26..1c3e8c98969 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameDirectoryManager.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot; import org.jackhuang.hmcl.util.PortablePath; import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.i18n.LocalizedText; @@ -146,9 +147,9 @@ private static boolean isGameDirectoryPath(GameDirectory gameDirectory, Portable private static final ChangeListener<@Nullable HMCLGameInstance> selectedRepositoryInstanceListener = (observable, oldValue, newValue) -> selectedInstance.set(newValue); - /// Handles completion of a full refresh by the selected repository. - private static final ChangeListener selectedRepositoryRefreshListener = - (observable, oldValue, newValue) -> onSelectedRepositoryRefreshed(); + /// Reacts when the selected repository publishes a new snapshot. + private static final ChangeListener selectedRepositorySnapshotListener = + (observable, oldValue, newValue) -> onSelectedRepositorySnapshotChanged(); /// Initializes game directory state from the stores loaded by [SettingsManager]. /// @@ -204,22 +205,25 @@ public static void init() { @Nullable HMCLGameRepository oldRepository = selectedRepository.get(); if (oldRepository != null) { oldRepository.selectedInstanceProperty().removeListener(selectedRepositoryInstanceListener); - oldRepository.refreshCountProperty().removeListener(selectedRepositoryRefreshListener); + oldRepository.snapshotProperty().removeListener(selectedRepositorySnapshotListener); } HMCLGameRepository repository = getOrCreateRepository(newValue); selectedRepository.set(repository); selectedInstance.set(repository.getSelectedInstance()); repository.selectedInstanceProperty().addListener(selectedRepositoryInstanceListener); - repository.refreshCountProperty().addListener(selectedRepositoryRefreshListener); + repository.snapshotProperty().addListener(selectedRepositorySnapshotListener); + if (repository.isLoaded()) { + onSelectedRepositorySnapshotChanged(); + } repository.refreshAsync().start(); }); selectedGameDirectory.set(currentGameDirectory != null ? currentGameDirectory : mergedGameDirectories.get(0)); } - /// Restores selection and notifies consumers after the selected repository finishes refreshing. - private static void onSelectedRepositoryRefreshed() { + /// Restores selection and notifies consumers after the selected repository publishes a loaded snapshot. + private static void onSelectedRepositorySnapshotChanged() { @Nullable HMCLGameRepository repository = selectedRepository.get(); - if (repository == null) { + if (repository == null || !repository.isLoaded()) { return; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java index 47e422ee0f0..aee7e753df2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameInstancePage.java @@ -29,6 +29,7 @@ import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameRepositorySnapshot; import org.jackhuang.hmcl.setting.GameSettings; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -65,11 +66,11 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator new SimpleObjectProperty<>(this, "instance"); private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); - /// Refreshes the page context when its repository finishes a full refresh. - private final ChangeListener repositoryRefreshListener = + /// Re-resolves the page context when its repository publishes a new snapshot. + private final ChangeListener repositorySnapshotListener = (observable, oldValue, newValue) -> checkSelectedInstance(); - /// Repository currently observed for full-refresh completion. + /// Repository currently observed for snapshot publications. private @Nullable HMCLGameRepository observedRepository; /// Last concrete instance displayed by this page. @@ -119,7 +120,7 @@ public GameInstancePage() { })); } - /// Observes refresh completion for the repository associated with the current page context. + /// Observes snapshot publications for the repository associated with the current page context. /// /// @param current the current page context, or `null` when the page has no context private void observeRepository(HMCLGameInstance.@Nullable Optional current) { @@ -129,11 +130,11 @@ private void observeRepository(HMCLGameInstance.@Nullable Optional current) { } if (observedRepository != null) { - observedRepository.refreshCountProperty().removeListener(repositoryRefreshListener); + observedRepository.snapshotProperty().removeListener(repositorySnapshotListener); } observedRepository = repository; if (repository != null) { - repository.refreshCountProperty().addListener(repositoryRefreshListener); + repository.snapshotProperty().addListener(repositorySnapshotListener); } } 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 2ebb171e33e..25a4b1a8a6d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -20,8 +20,6 @@ import com.google.gson.JsonParseException; import javafx.application.Platform; import javafx.beans.property.ObjectProperty; -import javafx.beans.property.ReadOnlyLongProperty; -import javafx.beans.property.ReadOnlyLongWrapper; import javafx.beans.property.ReadOnlyObjectProperty; import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.task.Task; @@ -96,9 +94,6 @@ private static boolean hasClassicInstance(Path baseDirectory) { /// Published snapshot, always updated on the JavaFX application thread when the toolkit is live. private final ObjectProperty snapshot; - /// Number of completed full refreshes. - private final ReadOnlyLongWrapper refreshCount; - /// Whether at least one full refresh has completed since the base directory was set. private volatile boolean loaded; @@ -109,7 +104,6 @@ public DefaultGameRepository(Path baseDirectory) { DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); initial.seal(); this.snapshot = new SimpleObjectProperty<>(initial); - this.refreshCount = new ReadOnlyLongWrapper(this, "refreshCount"); } /// Creates the repository layout rooted at the given directory. @@ -119,9 +113,10 @@ public DefaultGameRepository(Path baseDirectory) { protected abstract DefaultGameRepositoryLayout createLayout(Path baseDirectory); public void setBaseDirectory(Path baseDirectory) { + // Mark unloaded before publishing so snapshot listeners do not treat the empty snapshot as ready. + this.loaded = false; DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); publishSnapshot(initial); - this.loaded = false; } /// {@inheritDoc} @@ -143,25 +138,6 @@ public ReadOnlyObjectProperty snapshotP return snapshot; } - /// Returns the number of completed full repository refreshes. - /// - /// The property is incremented after a refreshed snapshot is published and [#isLoaded()] becomes - /// `true`. When the JavaFX toolkit is running, listeners are notified on its application thread. - /// Snapshot publications caused by operations such as saving or renaming an instance do not - /// increment this property. - /// - /// @return the read-only refresh-count property - public final ReadOnlyLongProperty refreshCountProperty() { - return refreshCount.getReadOnlyProperty(); - } - - /// Returns the number of completed full repository refreshes. - /// - /// @return the completed refresh count - public final long getRefreshCount() { - return refreshCount.get(); - } - /// Seals `newSnapshot` if needed and publishes it as the current repository snapshot. /// /// When the JavaFX toolkit is running, the property is updated on the JavaFX application thread @@ -172,12 +148,10 @@ public final long getRefreshCount() { /// unless it is a freshly built replacement protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { newSnapshot.seal(); - setSnapshotOnFxThread(newSnapshot); - } + runOnFxThreadAndWait(() -> { - /// Sets [#snapshot] on the JavaFX application thread when possible. - private void setSnapshotOnFxThread(DefaultGameRepositorySnapshot newSnapshot) { - runOnFxThreadAndWait(() -> snapshot.set(newSnapshot)); + snapshot.set(newSnapshot); + }); } /// Runs an action on the JavaFX application thread and waits for its completion. @@ -279,10 +253,9 @@ public void refresh() { newSnapshot.clear(); newSnapshot.putAll(loadedInstances); - publishSnapshot(newSnapshot); - + // Mark loaded before publishing so snapshot listeners observe a ready repository. loaded = true; - runOnFxThreadAndWait(() -> refreshCount.set(refreshCount.get() + 1)); + publishSnapshot(newSnapshot); } /// Loads one instance directory without renaming on-disk JSON or jar files. From 96b449a9fd8f7b047754df646fde172daea0b063 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 21:18:52 +0800 Subject: [PATCH 088/199] feat(GameVerification): refactor game version handling to use GameVersionNumber type --- .../org/jackhuang/hmcl/game/LauncherHelper.java | 13 +++++-------- .../org/jackhuang/hmcl/util/NativePatcher.java | 14 +++++++------- .../download/game/GameVerificationFixTask.java | 8 ++++---- .../hmcl/game/DefaultGameInstanceTest.java | 2 +- 4 files changed, 17 insertions(+), 20 deletions(-) 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 3fcdb8e8626..4f23579a744 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -161,7 +161,7 @@ private void launch0() { AtomicReference version = new AtomicReference<>( LaunchManifestPreparation.prepare( repository, gameInstance.getResolvedManifest().launchManifest())); - Optional gameVersion = repository.getGameVersion(version.get()); + GameVersionNumber gameVersion = gameInstance.getVersion(); boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally(); CountDownLatch launchingLatch = new CountDownLatch(1); List javaAgents = new ArrayList<>(0); @@ -172,7 +172,7 @@ private void launch0() { TaskExecutor executor = checkGameState(repository, setting, version.get()) .thenComposeAsync(java -> { javaVersionRef.set(Objects.requireNonNull(java)); - version.set(NativePatcher.patchNative(gameInstance, version.get(), gameVersion.orElse(null), java, setting, javaArguments)); + version.set(NativePatcher.patchNative(gameInstance, version.get(), gameVersion, java, setting, javaArguments)); if (setting.getInheritable(GameSettings::notCheckGameProperty)) return null; return Task.allOf( @@ -194,7 +194,7 @@ private void launch0() { }), Task.composeAsync(() -> { if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS - || !(setting.getRenderer(GameVersionNumber.asGameVersion(gameVersion)) instanceof Renderer.Driver renderer) + || !(setting.getRenderer(gameVersion) instanceof Renderer.Driver renderer) || renderer.mesaDriverName() == null) return null; @@ -222,10 +222,7 @@ private void launch0() { ); }).withStage("launch.state.dependencies") .thenComposeAsync(() -> { - if (gameVersion.isEmpty()) { - return null; - } - return new GameVerificationFixTask(gameInstance, gameVersion.get(), version.get()); + return new GameVerificationFixTask(gameInstance, gameVersion, version.get()); }) .thenComposeAsync(() -> { if (setting.getInheritable(GameSettings::allowAutoAgentProperty) @@ -301,7 +298,7 @@ private void launch0() { launchOptions, launcherVisibility == LauncherVisibility.CLOSE ? null // Unnecessary to start listening to game process output when close launcher immediately after game launched. - : new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.isPresent()) + : new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.compareTo(GameVersionNumber.unknown()) != 0) ); }).thenComposeAsync(launcher -> { // launcher is prev task's result if (scriptFile == null) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java index 28d6738fc4a..0221fca5b23 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java @@ -29,6 +29,7 @@ import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; @@ -74,12 +75,13 @@ public static boolean needPatchMemoryUtil(GameInstanceManifest manifest, int jav } public static GameInstanceManifest patchNative(DefaultGameInstance instance, - GameInstanceManifest manifest, String gameVersion, + GameInstanceManifest manifest, + @NotNull GameVersionNumber gameVersion, JavaRuntime javaVersion, GameSettings.Effective settings, List javaArguments) { if (settings.getInheritable(GameSettings::useCustomNativesProperty)) { - if (gameVersion != null && GameVersionNumber.compare(gameVersion, "1.19") < 0) + if (gameVersion.compareTo("1.19") < 0) return manifest; ArrayList newLibraries = new ArrayList<>(); @@ -99,8 +101,8 @@ public static GameInstanceManifest patchNative(DefaultGameInstance instance, final boolean useNativeGLFW = settings.getInheritable(GameSettings::useNativeGLFWProperty); final boolean useNativeOpenAL = settings.getInheritable(GameSettings::useNativeOpenALProperty); - if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() && (useNativeGLFW || useNativeOpenAL) - && gameVersion != null && GameVersionNumber.compare(gameVersion, "1.19") >= 0) { + if (OperatingSystem.CURRENT_OS.isLinuxOrBSD() + && (useNativeGLFW || useNativeOpenAL) && gameVersion.compareTo("1.19") >= 0) { manifest = manifest.withLibraries(manifest.getLibraries().stream() .filter(library -> { @@ -122,7 +124,6 @@ public static GameInstanceManifest patchNative(DefaultGameInstance instance, OperatingSystem os = javaVersion.getPlatform().getOperatingSystem(); Architecture arch = javaVersion.getArchitecture(); - GameVersionNumber gameVersionNumber = gameVersion != null ? GameVersionNumber.asGameVersion(gameVersion) : null; if (settings.getInheritable(GameSettings::notPatchNativesProperty)) return manifest; @@ -131,8 +132,7 @@ public static GameInstanceManifest patchNative(DefaultGameInstance instance, return manifest; if (arch == Architecture.ARM64 && (os == OperatingSystem.MACOS || os == OperatingSystem.WINDOWS) - && gameVersionNumber != null - && gameVersionNumber.compareTo("1.19") >= 0) + && gameVersion.compareTo("1.19") >= 0) return manifest; Map replacements = getNatives(javaVersion.getPlatform()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java index f53bcff40f6..54f4cb27165 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java @@ -39,7 +39,7 @@ public final class GameVerificationFixTask extends Task { private final GameInstance instance; /// The detected Minecraft version. - private final String gameVersion; + private final GameVersionNumber gameVersion; /// The effective launch manifest used to detect Forge. private final GameInstanceManifest manifest; @@ -49,7 +49,7 @@ public final class GameVerificationFixTask extends Task { /// @param instance the instance whose client jar may be modified /// @param gameVersion the detected Minecraft version /// @param manifest the effective launch manifest used to detect Forge - public GameVerificationFixTask(GameInstance instance, String gameVersion, GameInstanceManifest manifest) { + public GameVerificationFixTask(GameInstance instance, GameVersionNumber gameVersion, GameInstanceManifest manifest) { this.instance = instance; this.gameVersion = gameVersion; this.manifest = manifest; @@ -63,9 +63,9 @@ public GameVerificationFixTask(GameInstance instance, String gameVersion, GameIn @Override public void execute() throws IOException { Path jar = instance.getInstanceJarFile(); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, gameVersion); + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, gameVersion.toString()); - if (Files.exists(jar) && GameVersionNumber.compare(gameVersion, "1.6") < 0 && analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) { + if (Files.exists(jar) && gameVersion.compareTo("1.6") < 0 && analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) { try (FileSystem fs = CompressingUtils.createWritableZipFileSystem(jar, StandardCharsets.UTF_8)) { Files.deleteIfExists(fs.getPath("META-INF/MOJANG_C.DSA")); Files.deleteIfExists(fs.getPath("META-INF/MOJANG_C.SF")); diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java index f0de6accb01..b4b76ea57c5 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -345,7 +345,7 @@ public void testVerificationFixKeepsCapturedInstance(@TempDir Path tempDirectory tempDirectory.resolve("versions/instance/current.json")); writeSignedJar(current.getInstanceJarFile()); - new GameVerificationFixTask(captured, "1.5.2", manifest).execute(); + new GameVerificationFixTask(captured, GameVersionNumber.asGameVersion("1.5.2"), manifest).execute(); assertFalse(hasZipEntry(captured.getInstanceJarFile(), "META-INF/MOJANG_C.DSA")); assertFalse(hasZipEntry(captured.getInstanceJarFile(), "META-INF/MOJANG_C.SF")); From 932b4b6ec9345c66e25e5adf4730fcfa03c14e40 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 21:23:51 +0800 Subject: [PATCH 089/199] feat(GameCrashWindow): refactor to use HMCLGameInstance and streamline game version handling --- .../jackhuang/hmcl/game/LauncherHelper.java | 12 ++-- .../jackhuang/hmcl/ui/GameCrashWindow.java | 27 +++---- .../hmcl/ui/GameCrashWindowTest.java | 71 ------------------- .../hmcl/game/DefaultGameRepository.java | 9 --- .../jackhuang/hmcl/game/GameRepository.java | 9 --- 5 files changed, 17 insertions(+), 111 deletions(-) delete mode 100644 HMCL/src/test/java/org/jackhuang/hmcl/ui/GameCrashWindowTest.java 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 4f23579a744..8694f73d4a3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -169,7 +169,7 @@ private void launch0() { AtomicReference javaVersionRef = new AtomicReference<>(); - TaskExecutor executor = checkGameState(repository, setting, version.get()) + TaskExecutor executor = checkGameState(gameInstance, setting, version.get()) .thenComposeAsync(java -> { javaVersionRef.set(Objects.requireNonNull(java)); version.set(NativePatcher.patchNative(gameInstance, version.get(), gameVersion, java, setting, javaArguments)); @@ -437,8 +437,8 @@ public void onStop(boolean success, TaskExecutor executor) { executor.start(); } - private static Task checkGameState(HMCLGameRepository repository, GameSettings.Effective setting, GameInstanceManifest manifest) { - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null)); + private static Task checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) { + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, gameInstance.getVersion().toString()); GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT)); Task getJavaTask = Task.supplyAsync(() -> { @@ -505,7 +505,7 @@ private static Task checkGameState(HMCLGameRepository repository, G } if (targetJavaVersion != null && supportedVersions.contains(targetJavaVersion)) { - downloadJava(targetJavaVersion, repository) + downloadJava(targetJavaVersion, gameInstance.getRepository()) .whenCompleteAsync((downloadedJava, exception) -> { if (exception == null) { future.complete(downloadedJava); @@ -582,7 +582,7 @@ else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA)) gameJavaVersion = null; if (gameJavaVersion != null) { - FXUtils.runInFX(() -> downloadJava(gameJavaVersion, repository).whenCompleteAsync((downloadedJava, throwable) -> { + FXUtils.runInFX(() -> downloadJava(gameJavaVersion, gameInstance.getRepository()).whenCompleteAsync((downloadedJava, throwable) -> { if (throwable == null) { setting.setJavaAutoSelected(); future.complete(downloadedJava); @@ -1052,7 +1052,7 @@ public void onExit(int exitCode, ExitType exitType) { if (exitType != ExitType.NORMAL) { gameInstance.markLaunchedAbnormally(); - runLater(() -> new GameCrashWindow(process, exitType, repository, manifest, launchOptions, logs).show()); + runLater(() -> new GameCrashWindow(process, exitType, gameInstance, launchOptions, logs).show()); } checkExit(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index f741a6916b4..dda4858a7f2 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -73,7 +73,7 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; public class GameCrashWindow extends Stage { - private final GameInstanceManifest manifest; + private final HMCLGameInstance gameInstance; private final String memory; private final String total_memory; private final String java; @@ -83,7 +83,6 @@ public class GameCrashWindow extends Stage { private final TextFlow feedbackTextFlow = new TextFlow(); private final ManagedProcess managedProcess; - private final DefaultGameRepository repository; private final ProcessListener.ExitType exitType; private final LaunchOptions launchOptions; private final View view; @@ -91,16 +90,15 @@ public class GameCrashWindow extends Stage { private final List logs; - public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, DefaultGameRepository repository, GameInstanceManifest manifest, LaunchOptions launchOptions, List logs) { + public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType exitType, HMCLGameInstance gameInstance, LaunchOptions launchOptions, List logs) { Themes.applyNativeDarkMode(this); this.managedProcess = managedProcess; this.exitType = exitType; - this.repository = repository; - this.manifest = manifest; + this.gameInstance = gameInstance; this.launchOptions = launchOptions; this.logs = logs; - this.analyzer = LibraryAnalyzer.analyze(manifest, repository.getGameVersion(manifest).orElse(null)); + this.analyzer = LibraryAnalyzer.analyze(gameInstance.getResolvedManifest(), gameInstance.getVersion().toString()); memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-"); @@ -142,10 +140,7 @@ private void analyzeCrashReport() { return pair(CrashReportAnalyzer.analyze(rawLog), crashReport != null ? CrashReportAnalyzer.findKeywordsFromCrashReport(crashReport) : new HashSet<>()); }), Task.supplyAsync(() -> { - DefaultGameInstance gameInstance = repository.getSnapshot().findInstance(manifest.id()); - Path runDirectory = gameInstance != null - ? gameInstance.getRunDirectory() - : repository.getBaseDirectory(); + Path runDirectory = gameInstance.getRunDirectory(); Path latestLog = runDirectory.resolve("logs/latest.log"); if (!Files.isReadable(latestLog)) { return pair(new HashSet(), new HashSet()); @@ -295,7 +290,7 @@ private CompletableFuture exportGameCrashInfo() { } }); - return LogExporter.exportLogs(logFile, repository, launchOptions.getInstanceId(), logs, + return LogExporter.exportLogs(logFile, gameInstance.getRepository(), launchOptions.getInstanceId(), logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString(), path -> { try { @@ -346,10 +341,10 @@ private final class View extends VBox { launcher.setTitle(i18n("launcher")); launcher.setSubtitle(Metadata.VERSION); - TwoLineListItem version = new TwoLineListItem(); - version.getStyleClass().setAll("two-line-item-second-large"); - version.setTitle(i18n("game.version")); - version.setSubtitle(GameCrashWindow.this.manifest.id().toString()); + TwoLineListItem instance = new TwoLineListItem(); + instance.getStyleClass().setAll("two-line-item-second-large"); + instance.setTitle(i18n("game.version")); + instance.setSubtitle(GameCrashWindow.this.gameInstance.getId().toString()); TwoLineListItem total_memory = new TwoLineListItem(); total_memory.getStyleClass().setAll("two-line-item-second-large"); @@ -376,7 +371,7 @@ private final class View extends VBox { arch.setTitle(i18n("system.architecture")); arch.setSubtitle(Architecture.SYSTEM_ARCH.getDisplayName()); - infoPane.getChildren().setAll(launcher, version, total_memory, memory, java, os, arch); + infoPane.getChildren().setAll(launcher, instance, total_memory, memory, java, os, arch); } HBox moddedPane = new HBox(8); diff --git a/HMCL/src/test/java/org/jackhuang/hmcl/ui/GameCrashWindowTest.java b/HMCL/src/test/java/org/jackhuang/hmcl/ui/GameCrashWindowTest.java deleted file mode 100644 index 0fb1e46b021..00000000000 --- a/HMCL/src/test/java/org/jackhuang/hmcl/ui/GameCrashWindowTest.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2021 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.ui; - -import org.jackhuang.hmcl.JavaFXLauncher; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.LaunchOptions; -import org.jackhuang.hmcl.java.JavaInfo; -import org.jackhuang.hmcl.game.Log; -import org.jackhuang.hmcl.launch.ProcessListener; -import org.jackhuang.hmcl.java.JavaRuntime; -import org.jackhuang.hmcl.util.platform.ManagedProcess; -import org.jackhuang.hmcl.util.platform.Platform; -import org.junit.jupiter.api.Disabled; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Arrays; -import java.util.concurrent.CountDownLatch; -import java.util.stream.Collectors; - -public class GameCrashWindowTest { - - @Test - @Disabled - public void test() throws Exception { - JavaFXLauncher.start(); - - ManagedProcess process = new ManagedProcess(null, Arrays.asList("commands", "2")); - - String logs = Files.readString(new File("../HMCLCore/src/test/resources/logs/too_old_java.txt").toPath()); - - CountDownLatch latch = new CountDownLatch(1); - FXUtils.runInFX(() -> { - Path workingPath = Path.of(System.getProperty("user.dir")); - - GameCrashWindow window = new GameCrashWindow(process, ProcessListener.ExitType.APPLICATION_ERROR, null, - new GameInstanceManifest(new GameInstanceID("Classic")), - new LaunchOptions.Builder() - .setJava(new JavaRuntime(workingPath, new JavaInfo(Platform.SYSTEM_PLATFORM, "16", null), false, false)) - .setGameDir(workingPath) - .create(), - Arrays.stream(logs.split("\\n")) - .map(Log::new) - .collect(Collectors.toList())); - - window.showAndWait(); - - latch.countDown(); - }); - latch.await(); - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 25a4b1a8a6d..410104abc1c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -483,15 +483,6 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { } } - @Override - public Optional getGameVersion(GameInstanceID instanceId) throws NoSuchGameInstanceException { - GameVersionNumber version = getInstance(instanceId).getVersion(); - if (version == GameVersionNumber.unknown()) { - return Optional.empty(); - } - return Optional.of(version.toString()); - } - @Override public Optional getGameVersion(GameInstanceManifest manifest) { DefaultGameInstance instance = findSnapshotInstance(manifest.id()); 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 06f6dafcf66..04d8baab2fa 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -147,15 +147,6 @@ default Path getInstanceRoot(GameInstanceID instanceId) { /// @return the detected Minecraft game version, or empty if it cannot be determined Optional getGameVersion(GameInstanceManifest manifest); - /// Detects the Minecraft game version associated with an instance. - /// - /// @param instanceId the instance id - /// @return the detected Minecraft game version, or empty if it cannot be determined - /// @throws NoSuchGameInstanceException if the instance is not loaded in this repository - default Optional getGameVersion(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getGameVersion(getInstanceManifest(instanceId)); - } - /// Renames an instance and updates repository-managed references. /// /// @param from the current instance id From f35b7ef12050f070e878256e556cb029c4d5600f Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 21:38:06 +0800 Subject: [PATCH 090/199] feat(Installers): refactor to use GameVersionNumber for improved version handling --- .../org/jackhuang/hmcl/ui/InstallerItem.java | 8 +- .../ui/download/AbstractInstallersPage.java | 3 +- .../UpdateInstallerWizardProvider.java | 20 ++- .../hmcl/ui/instances/InstallerListPage.java | 119 ++++++++---------- 4 files changed, 67 insertions(+), 83 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java index a747c1a34c9..5984f409279 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java @@ -175,7 +175,7 @@ private void mutualIncompatible(Map> incompati } } - public InstallerItemGroup(String gameVersion, Style style) { + public InstallerItemGroup(GameVersionNumber gameVersion, Style style) { game = new InstallerItem(MINECRAFT, style); InstallerItem fabric = new InstallerItem(FABRIC, style); InstallerItem fabricApi = new InstallerItem(FABRIC_API, style); @@ -226,7 +226,7 @@ public InstallerItemGroup(String gameVersion, Style style) { } if (gameVersion != null) { - game.versionProperty.set(new InstalledState(gameVersion, false, false)); + game.versionProperty.set(new InstalledState(gameVersion.toString(), false, false)); } InstallerItem[] all = {game, forge, neoForge, liteLoader, optiFine, fabric, fabricApi, quilt, quiltApi, legacyfabric, legacyfabricApi, cleanroom}; @@ -245,9 +245,9 @@ public InstallerItemGroup(String gameVersion, Style style) { if (gameVersion == null) { this.libraries = all; - } else if (gameVersion.equals("1.12.2")) { + } else if (gameVersion.compareTo("1.12.2") == 0) { this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine}; - } else if (GameVersionNumber.compare(gameVersion, "1.13.2") <= 0) { + } else if (gameVersion.compareTo("1.13.2") <= 0) { this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi}; } else { this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi}; diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java index 5ee8e6881cf..93245c07b2b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java @@ -41,6 +41,7 @@ import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.ui.wizard.WizardPage; import org.jackhuang.hmcl.util.SettingsMap; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import static org.jackhuang.hmcl.setting.SettingsManager.state; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -58,7 +59,7 @@ public abstract class AbstractInstallersPage extends Control implements WizardPa public AbstractInstallersPage(WizardController controller, String gameVersion, DownloadProvider downloadProvider) { this.controller = controller; - this.group = new InstallerItem.InstallerItemGroup(gameVersion, getInstallerItemStyle()); + this.group = new InstallerItem.InstallerItemGroup(GameVersionNumber.asGameVersion(gameVersion), getInstallerItemStyle()); for (InstallerItem library : group.getLibraries()) { String libraryId = library.getLibraryId(); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java index 267da620fb9..b8341c2ab16 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 @@ -22,7 +22,7 @@ import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask; import org.jackhuang.hmcl.download.game.LibraryDownloadException; import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.task.DownloadException; import org.jackhuang.hmcl.task.Task; @@ -46,22 +46,18 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public final class UpdateInstallerWizardProvider implements WizardProvider { - private final HMCLGameRepository repository; + private final HMCLGameInstance gameInstance; private final DefaultDependencyManager dependencyManager; - private final String gameVersion; - private final GameInstanceManifest manifest; private final String libraryId; private final String oldLibraryVersion; private final DownloadProvider downloadProvider; - public UpdateInstallerWizardProvider(@NotNull HMCLGameRepository repository, @NotNull String gameVersion, @NotNull GameInstanceManifest manifest, @NotNull String libraryId, @Nullable String oldLibraryVersion) { - this.repository = repository; - this.gameVersion = gameVersion; - this.manifest = manifest; + public UpdateInstallerWizardProvider(@NotNull HMCLGameInstance gameInstance, @NotNull String libraryId, @Nullable String oldLibraryVersion) { + this.gameInstance = gameInstance; this.libraryId = libraryId; this.oldLibraryVersion = oldLibraryVersion; this.downloadProvider = DownloadProviders.getDownloadProvider(); - this.dependencyManager = repository.getDependency(downloadProvider); + this.dependencyManager = gameInstance.getRepository().getDependency(downloadProvider); } @Override @@ -76,7 +72,7 @@ public Object finish(SettingsMap settings) { // We remove library but not save it, // so if installation failed will not break down current version. - Task ret = Task.supplyAsync(() -> manifest); + Task ret = Task.supplyAsync(gameInstance::getManifest); var hints = new ArrayList(); for (Object value : settings.asStringMap().values()) { if (value instanceof RemoteVersion remoteVersion) { @@ -91,7 +87,7 @@ public Object finish(SettingsMap settings) { } } - return ret.thenComposeAsync(repository::saveAsync).thenComposeAsync(repository.refreshAsync()).withStagesHints(hints); + return ret.thenComposeAsync(gameInstance.getRepository()::saveAsync).thenComposeAsync(gameInstance.getRepository()::refreshAsync).withStagesHints(hints); } @Override @@ -103,7 +99,7 @@ public Node createPage(WizardController controller, int step, SettingsMap settin controller.onFinish(); } else if ("game".equals(libraryId)) { String newGameVersion = ((RemoteVersion) settings.get(libraryId)).getSelfVersion(); - controller.onNext(new AdditionalInstallersPage(newGameVersion, manifest, controller, repository, downloadProvider)); + controller.onNext(new AdditionalInstallersPage(newGameVersion, gameInstance.getManifest(), controller, repository, downloadProvider)); } else { Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()), i18n("install.change_version"), controller::onFinish, controller::onCancel); 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 6b3c759685a..beecb38f620 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java @@ -17,13 +17,11 @@ */ package org.jackhuang.hmcl.ui.instances; -import javafx.application.Platform; import javafx.beans.value.ObservableValue; import javafx.scene.Node; import javafx.scene.control.Skin; import javafx.stage.FileChooser; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.task.Schedulers; @@ -41,7 +39,6 @@ import java.util.Collections; import java.util.List; import java.util.Objects; -import java.util.concurrent.CompletableFuture; import static org.jackhuang.hmcl.ui.FXUtils.runInFX; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -49,8 +46,6 @@ public class InstallerListPage extends ListPageBase { private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); private @Nullable HMCLGameInstance gameInstance; - private GameInstanceManifest manifest; - private String gameVersion; /// Creates an installer list that reloads when `instanceContext` changes. /// @@ -78,80 +73,72 @@ public void loadInstance(HMCLGameInstance.Optional instance) { this.gameInstance = instance.instance(); if (gameInstance == null) { itemsProperty().clear(); - this.manifest = null; - this.gameVersion = null; return; } HMCLGameRepository repository = gameInstance.getRepository(); - this.manifest = gameInstance.getManifest(); - this.gameVersion = null; - CompletableFuture.supplyAsync(() -> { - gameVersion = repository.getGameVersion(manifest).orElse(null); + LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(gameInstance.getResolvedManifest(), gameInstance.getVersion().toString()); - return LibraryAnalyzer.analyze(gameInstance.getResolvedManifest(), gameVersion); - }).thenAcceptAsync(analyzer -> { - itemsProperty().clear(); + itemsProperty().clear(); - InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameVersion, InstallerItem.Style.LIST_ITEM); + InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM); - // Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine - for (InstallerItem item : group.getLibraries()) { - String libraryId = item.getLibraryId(); + // Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine + for (InstallerItem item : group.getLibraries()) { + String libraryId = item.getLibraryId(); - // Skip fabric-api and quilt-api and legacyfabric-api - if (libraryId.endsWith("-api")) { - continue; - } + // Skip fabric-api and quilt-api and legacyfabric-api + if (libraryId.endsWith("-api")) { + continue; + } - String libraryVersion = analyzer.getVersion(libraryId).orElse(null); + String libraryVersion = analyzer.getVersion(libraryId).orElse(null); - if (libraryVersion != null) { - item.versionProperty().set(new InstallerItem.InstalledState( - libraryVersion, - analyzer.getLibraryStatus(libraryId) != LibraryAnalyzer.LibraryMark.LibraryStatus.CLEAR, - false - )); - } else { - item.versionProperty().set(null); - } + if (libraryVersion != null) { + item.versionProperty().set(new InstallerItem.InstalledState( + libraryVersion, + analyzer.getLibraryStatus(libraryId) != LibraryAnalyzer.LibraryMark.LibraryStatus.CLEAR, + false + )); + } else { + item.versionProperty().set(null); + } - item.setOnInstall(() -> { - Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(repository, gameVersion, manifest, libraryId, libraryVersion)); - }); + item.setOnInstall(() -> { + Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, libraryId, libraryVersion)); + }); - item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId) - .thenComposeAsync(repository::saveAsync) - .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) - .start()); + item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), libraryId) + .thenComposeAsync(repository::saveAsync) + .withComposeAsync(repository.refreshAsync()) + .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) + .start()); - itemsProperty().add(item); - } + itemsProperty().add(item); + } - // other third-party libraries which are unable to manage. - for (LibraryAnalyzer.LibraryMark mark : analyzer) { - String libraryId = mark.getLibraryId(); - String libraryVersion = mark.getLibraryVersion(); - if ("mcbbs".equals(libraryId)) - continue; - - // we have done this library above. - if (LibraryAnalyzer.LibraryType.fromPatchId(libraryId) != null) - continue; - - InstallerItem installerItem = new InstallerItem(libraryId, InstallerItem.Style.LIST_ITEM); - installerItem.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false)); - installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(manifest, libraryId) - .thenComposeAsync(repository::saveAsync) - .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) - .start()); - - itemsProperty().add(installerItem); - } - }, Platform::runLater); + // other third-party libraries which are unable to manage. + for (LibraryAnalyzer.LibraryMark mark : analyzer) { + String libraryId = mark.getLibraryId(); + String libraryVersion = mark.getLibraryVersion(); + if ("mcbbs".equals(libraryId)) + continue; + + // we have done this library above. + if (LibraryAnalyzer.LibraryType.fromPatchId(libraryId) != null) + continue; + + InstallerItem installerItem = new InstallerItem(libraryId, InstallerItem.Style.LIST_ITEM); + installerItem.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false)); + installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), libraryId) + .thenComposeAsync(repository::saveAsync) + .withComposeAsync(repository.refreshAsync()) + .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) + .start()); + + itemsProperty().add(installerItem); + } } private void reloadCurrentInstance() { @@ -168,12 +155,12 @@ public void installOffline() { } private void doInstallOffline(Path file) { - if (gameInstance == null || manifest == null) { + if (gameInstance == null) { return; } HMCLGameRepository repository = gameInstance.getRepository(); - Task task = repository.getDependency().installLibraryAsync(manifest, file) + Task task = repository.getDependency().installLibraryAsync(gameInstance.getManifest(), file) .thenComposeAsync(repository::saveAsync) .thenComposeAsync(repository.refreshAsync()); task.setName(i18n("install.installer.install_offline")); From 0b6a129a0909dea0ef1ebae8de7aa409da3b6916 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 6 Aug 2026 22:09:34 +0800 Subject: [PATCH 091/199] feat(GameComponentType): add enum for game component types and library matching logic --- .../hmcl/game/GameComponentType.java | 263 ++++++++++++++++++ 1 file changed, 263 insertions(+) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java new file mode 100644 index 00000000000..79da32b97b1 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -0,0 +1,263 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/// @author Glavo +@NotNullByDefault +public enum GameComponentType { + GAME("game", null) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return true; + } + }, + LEGACY_FABRIC("legacyfabric", ModLoaderType.LEGACY_FABRIC) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + if ("net.fabricmc".equals(library.groupId()) && "fabric-loader".equals(library.artifactId())) { + for (Library l : libraries) { + if ("net.legacyfabric".equals(l.groupId())) { + return true; + } + } + } + return false; + } + }, + LEGACY_FABRIC_API("legacyfabric-api", null) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "net.legacyfabric".equals(library.groupId()) && "legacyfabric-api".equals(library.artifactId()); + } + }, + FABRIC("fabric", ModLoaderType.FABRIC) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + if ("net.fabricmc".equals(library.groupId()) && "fabric-loader".equals(library.artifactId())) { + for (Library l : libraries) { + if ("net.legacyfabric".equals(l.groupId())) { + return false; + } + } + + return true; + } + + return false; + } + }, + FABRIC_API("fabric-api", null) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "net.fabricmc".equals(library.groupId()) && "fabric-api".equals(library.artifactId()); + } + }, + FORGE("forge", ModLoaderType.FORGE) { + private final Pattern FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?[0-9.]+)(-([0-9.]+))?$"); + + @Override + protected @Nullable String patchVersion(GameInstanceManifest manifest, String libraryVersion) { + Matcher matcher = FORGE_VERSION_MATCHER.matcher(libraryVersion); + if (matcher.find()) { + return matcher.group("forge"); + } + return super.patchVersion(manifest, libraryVersion); + } + + @Override + protected boolean matchLibrary(Library library, List libraries) { + for (Library l : libraries) { + if (NEO_FORGE.matchLibrary(l, libraries)) { + return false; + } + } + + return "net.minecraftforge".equals(library.groupId()) && ("forge".equals(library.artifactId()) || "fmlloader".equals(library.artifactId())); + } + }, + CLEANROOM("cleanroom", ModLoaderType.CLEANROOM) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "com.cleanroommc".equals(library.groupId()) && "cleanroom".equals(library.artifactId()); + } + }, + NEO_FORGE("neoforge", ModLoaderType.NEO_FORGE) { + private final Pattern NEO_FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?[0-9.]+)(-([0-9.]+))?$"); + + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "net.neoforged.fancymodloader".equals(library.groupId()) && ("core".equals(library.artifactId()) || "loader".equals(library.artifactId())); + } + + @Override + protected @Nullable String patchVersion(GameInstanceManifest manifest, String libraryVersion) { + String res = scanVersion(manifest); + if (res != null) { + return res; + } + + for (GameInstancePatch patch : manifest.getPatches()) { + res = scanPatch(patch); + if (res != null) { + return res; + } + } + + Matcher matcher = NEO_FORGE_VERSION_MATCHER.matcher(libraryVersion); + if (matcher.find()) { + return matcher.group("forge"); + } + + return libraryVersion; + } + + private @Nullable String scanVersion(GameInstanceManifest manifest) { + if (manifest.arguments() == null) { + return null; + } + List gameArguments = manifest.arguments().game(); + if (gameArguments == null) { + return null; + } + + for (int i = 0; i < gameArguments.size() - 1; i++) { + Argument argument = gameArguments.get(i); + if (argument instanceof StringArgument) { + String argumentValue = ((StringArgument) argument).argument(); + if ("--fml.neoForgeVersion".equals(argumentValue) || "--fml.forgeVersion".equals(argumentValue)) { + Argument next = gameArguments.get(i + 1); + if (next instanceof StringArgument) { + return ((StringArgument) next).argument(); + } + return null; // Normally, there should not be two --fml.neoForgeVersion argument. + } + } + } + return null; + } + + private @Nullable String scanPatch(GameInstancePatch patch) { + Arguments optArgument = patch.arguments(); + if (optArgument == null) { + return null; + } + List gameArguments = optArgument.game(); + if (gameArguments == null) { + return null; + } + + for (int i = 0; i < gameArguments.size() - 1; i++) { + Argument argument = gameArguments.get(i); + if (argument instanceof StringArgument) { + String argumentValue = ((StringArgument) argument).argument(); + if ("--fml.neoForgeVersion".equals(argumentValue) || "--fml.forgeVersion".equals(argumentValue)) { + Argument next = gameArguments.get(i + 1); + if (next instanceof StringArgument) { + return ((StringArgument) next).argument(); + } + return null; + } + } + } + return null; + } + + }, + LITELOADER("liteloader", ModLoaderType.LITE_LOADER) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "com.mumfrey".equals(library.groupId()) && "liteloader".equals(library.artifactId()); + } + }, + OPTIFINE("optifine", null) { + private static final Set GROUPS = Set.of("net.optifine", "optifine"); + + @Override + protected boolean matchLibrary(Library library, List libraries) { + return GROUPS.contains(library.groupId()) && !library.artifactId().contains("launchwrapper"); + } + }, + QUILT("quilt", ModLoaderType.QUILT) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "org.quiltmc".equals(library.groupId()) && "quilt-loader".equals(library.artifactId()); + } + }, + QUILT_API("quilt-api", null) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "org.quiltmc".equals(library.groupId()) && "quilt-api".equals(library.artifactId()); + } + }, + BOOTSTRAP_LAUNCHER("", null) { + @Override + protected boolean matchLibrary(Library library, List libraries) { + return "cpw.mods".equals(library.groupId()) && "bootstraplauncher".equals(library.artifactId()); + } + }; + + private final String patchId; + private final @Nullable ModLoaderType modLoaderType; + + private static final Map PATCH_ID_MAP = new HashMap<>(); + + static { + for (GameComponentType type : values()) { + PATCH_ID_MAP.put(type.getPatchId(), type); + } + } + + GameComponentType(String patchId, @Nullable ModLoaderType modLoaderType) { + this.patchId = patchId; + this.modLoaderType = modLoaderType; + } + + public boolean isModLoader() { + return modLoaderType != null; + } + + public String getPatchId() { + return patchId; + } + + public @Nullable ModLoaderType getModLoaderType() { + return modLoaderType; + } + + public static @Nullable GameComponentType fromPatchId(String patchId) { + return PATCH_ID_MAP.get(patchId); + } + + protected abstract boolean matchLibrary(Library library, List libraries); + + protected @Nullable String patchVersion(GameInstanceManifest manifest, String libraryVersion) { + return libraryVersion; + } + +} From 1e1ecdb4ebffb50a70b0bde654f59f5e50f366ca Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 19:17:14 +0800 Subject: [PATCH 092/199] feat(GameComponentType): rename patchVersion method to getComponentVersion for clarity --- .../java/org/jackhuang/hmcl/game/GameComponentType.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java index 79da32b97b1..e963f524a95 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -82,12 +82,12 @@ protected boolean matchLibrary(Library library, List libraries) { private final Pattern FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?[0-9.]+)(-([0-9.]+))?$"); @Override - protected @Nullable String patchVersion(GameInstanceManifest manifest, String libraryVersion) { + protected @Nullable String getComponentVersion(GameInstanceManifest manifest, String libraryVersion) { Matcher matcher = FORGE_VERSION_MATCHER.matcher(libraryVersion); if (matcher.find()) { return matcher.group("forge"); } - return super.patchVersion(manifest, libraryVersion); + return super.getComponentVersion(manifest, libraryVersion); } @Override @@ -116,7 +116,7 @@ protected boolean matchLibrary(Library library, List libraries) { } @Override - protected @Nullable String patchVersion(GameInstanceManifest manifest, String libraryVersion) { + protected @Nullable String getComponentVersion(GameInstanceManifest manifest, String libraryVersion) { String res = scanVersion(manifest); if (res != null) { return res; @@ -256,7 +256,7 @@ public String getPatchId() { protected abstract boolean matchLibrary(Library library, List libraries); - protected @Nullable String patchVersion(GameInstanceManifest manifest, String libraryVersion) { + protected @Nullable String getComponentVersion(GameInstanceManifest manifest, String libraryVersion) { return libraryVersion; } From 96919cabf8989ba063af098954b226f112ada4f8 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 19:27:10 +0800 Subject: [PATCH 093/199] feat(GameComponentType): rename patchVersion method to getComponentVersion for clarity --- .../hmcl/game/GameComponentType.java | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java index e963f524a95..99e4ab4c600 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -31,7 +31,7 @@ /// @author Glavo @NotNullByDefault public enum GameComponentType { - GAME("game", null) { + GAME("game") { @Override protected boolean matchLibrary(Library library, List libraries) { return true; @@ -50,7 +50,7 @@ protected boolean matchLibrary(Library library, List libraries) { return false; } }, - LEGACY_FABRIC_API("legacyfabric-api", null) { + LEGACY_FABRIC_API("legacyfabric-api") { @Override protected boolean matchLibrary(Library library, List libraries) { return "net.legacyfabric".equals(library.groupId()) && "legacyfabric-api".equals(library.artifactId()); @@ -72,7 +72,7 @@ protected boolean matchLibrary(Library library, List libraries) { return false; } }, - FABRIC_API("fabric-api", null) { + FABRIC_API("fabric-api") { @Override protected boolean matchLibrary(Library library, List libraries) { return "net.fabricmc".equals(library.groupId()) && "fabric-api".equals(library.artifactId()); @@ -195,7 +195,7 @@ protected boolean matchLibrary(Library library, List libraries) { return "com.mumfrey".equals(library.groupId()) && "liteloader".equals(library.artifactId()); } }, - OPTIFINE("optifine", null) { + OPTIFINE("optifine") { private static final Set GROUPS = Set.of("net.optifine", "optifine"); @Override @@ -209,13 +209,13 @@ protected boolean matchLibrary(Library library, List libraries) { return "org.quiltmc".equals(library.groupId()) && "quilt-loader".equals(library.artifactId()); } }, - QUILT_API("quilt-api", null) { + QUILT_API("quilt-api") { @Override protected boolean matchLibrary(Library library, List libraries) { return "org.quiltmc".equals(library.groupId()) && "quilt-api".equals(library.artifactId()); } }, - BOOTSTRAP_LAUNCHER("", null) { + BOOTSTRAP_LAUNCHER("") { @Override protected boolean matchLibrary(Library library, List libraries) { return "cpw.mods".equals(library.groupId()) && "bootstraplauncher".equals(library.artifactId()); @@ -233,7 +233,12 @@ protected boolean matchLibrary(Library library, List libraries) { } } - GameComponentType(String patchId, @Nullable ModLoaderType modLoaderType) { + GameComponentType(String patchId) { + this.patchId = patchId; + this.modLoaderType = null; + } + + GameComponentType(String patchId, ModLoaderType modLoaderType) { this.patchId = patchId; this.modLoaderType = modLoaderType; } From 38840d6f786402600dffde9b033d88c28a127a72 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 20:12:13 +0800 Subject: [PATCH 094/199] feat(GameComponentAnalyzer): add GameComponentAnalyzer for enhanced game component analysis --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 4 +- .../UpdateInstallerWizardProvider.java | 4 +- .../hmcl/download/LibraryAnalyzer.java | 15 -- .../hmcl/game/GameComponentAnalyzer.java | 154 ++++++++++++++++++ .../hmcl/game/GameComponentType.java | 2 + .../hmcl/game/GameInstanceManifest.java | 14 ++ 6 files changed, 174 insertions(+), 19 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java 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 5d39fffecc3..7ce6c49d9d0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -199,7 +199,7 @@ public void applyDefaultIsolationSetting() { boolean isolated = switch (type) { case NEVER -> false; case ALWAYS -> true; - case MODDED -> LibraryAnalyzer.isModded(getResolvedManifest()); + case MODDED -> getResolvedManifest().isModded(); }; if (isolated) { @@ -466,7 +466,7 @@ private Image computeIconImage() { } GameInstanceManifest.Resolved resolvedManifest = getResolvedManifest(); - if (LibraryAnalyzer.isModded(resolvedManifest)) { + if (resolvedManifest.isModded()) { LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, null); if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) return GameInstanceIconType.FABRIC.getIcon(); 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 b8341c2ab16..5136dafb002 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 @@ -94,12 +94,12 @@ public Object finish(SettingsMap settings) { public Node createPage(WizardController controller, int step, SettingsMap settings) { switch (step) { case 0: - return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + libraryId)), gameVersion, downloadProvider, libraryId, () -> { + return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + libraryId)), gameInstance.getVersion().toString(), downloadProvider, libraryId, () -> { if (oldLibraryVersion == null) { controller.onFinish(); } else if ("game".equals(libraryId)) { String newGameVersion = ((RemoteVersion) settings.get(libraryId)).getSelfVersion(); - controller.onNext(new AdditionalInstallersPage(newGameVersion, gameInstance.getManifest(), controller, repository, downloadProvider)); + controller.onNext(new AdditionalInstallersPage(newGameVersion, gameInstance.getManifest(), controller, gameInstance.getRepository(), downloadProvider)); } else { Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()), i18n("install.change_version"), controller::onFinish, controller::onCancel); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java index 055644f3840..fd7bc4b672f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java @@ -50,10 +50,6 @@ public Optional getVersion(String type) { return Optional.ofNullable(libraries.get(type)).map(Pair::getValue); } - public Optional getLibrary(LibraryType type) { - return Optional.ofNullable(libraries.get(type.getPatchId())).map(Pair::getKey); - } - /** * If a library is provided in $.patches, it's structure is so clear that we can do any operation. * Otherwise, we must guess how are these libraries mixed. @@ -207,17 +203,6 @@ public static LibraryAnalyzer analyze(GameInstanceManifest manifest, String game return new LibraryAnalyzer(manifest, libraries); } - public static boolean isModded(GameInstanceManifest.Resolved resolved) { - String mainClass = resolved.launchManifest().mainClass(); - return mainClass != null && (LAUNCH_WRAPPER_MAIN.equals(mainClass) - || mainClass.startsWith("net.minecraftforge") - || mainClass.startsWith("net.neoforged") - || mainClass.startsWith("top.outlands") //Cleanroom - || mainClass.startsWith("net.fabricmc") - || mainClass.startsWith("org.quiltmc") - || mainClass.startsWith("cpw.mods")); - } - public Set getModLoaders() { return Arrays.stream(LibraryType.values()) .filter(LibraryType::isModLoader) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java new file mode 100644 index 00000000000..c2275a88768 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -0,0 +1,154 @@ +/* + * 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.LibraryAnalyzer; +import org.jackhuang.hmcl.util.Pair; +import org.jackhuang.hmcl.util.versioning.VersionNumber; +import org.jackhuang.hmcl.util.versioning.VersionRange; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.*; + +@NotNullByDefault +public final class GameComponentAnalyzer implements Iterable { + + private static GameComponentAnalyzer analyze( + GameInstanceManifest standaloneManifest, + GameInstanceManifest launchManifest, + @Nullable String gameVersion) { + var components = new EnumMap(GameComponentType.class); + + if (gameVersion != null) { + components.put(GameComponentType.GAME, new Mark(GameComponentType.GAME, gameVersion, Status.CLEAR)); + } + + List rawLibraries = launchManifest.getLibraries(); + for (Library library : rawLibraries) { + for (GameComponentType type : GameComponentType.ALL) { + if (type.matchLibrary(library, rawLibraries)) { + components.put(type, new Mark(type, type.getComponentVersion(standaloneManifest, library.version()), Status.CLEAR)); + break; + } + } + } + + for (GameInstancePatch patch : standaloneManifest.getPatches()) { + if (patch.isHidden() || patch.id() == null) continue; + + @Nullable GameComponentType type = GameComponentType.fromPatchId(patch.id()); + if (type != null) { + components.put(type, new Mark(type, patch.version(), Status.CLEAR)); + } + } + + return new GameComponentAnalyzer(standaloneManifest, components); + } + + + public static GameComponentAnalyzer analyze(GameInstanceManifest.Resolved resolved, @Nullable String gameVersion) { + return analyze(resolved.standaloneManifest(), resolved.launchManifest(), gameVersion); + } + + public static GameComponentAnalyzer analyze(GameInstanceManifest manifest, @Nullable String gameVersion) { + if (manifest.inheritsFrom() != null) + throw new IllegalArgumentException("LibraryAnalyzer can only analyze independent game version"); + + return analyze(manifest, manifest, gameVersion); + } + + private final GameInstanceManifest manifest; + private final Map components; + + private GameComponentAnalyzer(GameInstanceManifest manifest, Map components) { + this.manifest = manifest; + this.components = components; + } + + public @Nullable String getVersion(GameComponentType type) { + Mark mark = components.get(type); + return mark != null ? mark.version() : null; + } + + /// If a library is provided in `$.patches`, it's structure is so clear that we can do any operation. + /// Otherwise, we must guess how are these libraries mixed. + /// Maybe a guessing implementation will be provided in the future. But by now, we simply set it to JUST\_EXISTED. + public Status getLibraryStatus(GameComponentType type) { + return Status.JUST_EXISTED; // TODO + } + + @Override + public @NotNull 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, + Status status + ) { + } + + public static final String VANILLA_MAIN = "net.minecraft.client.main.Main"; + public static final String LAUNCH_WRAPPER_MAIN = "net.minecraft.launchwrapper.Launch"; + public static final String MOD_LAUNCHER_MAIN = "cpw.mods.modlauncher.Launcher"; + public static final String BOOTSTRAP_LAUNCHER_MAIN = "cpw.mods.bootstraplauncher.BootstrapLauncher"; + public static final String FORGE_BOOTSTRAP_MAIN = "net.minecraftforge.bootstrap.ForgeBootstrap"; + public static final String NEO_FORGE_BOOTSTRAP_MAIN = "net.neoforged.fml.startup.Client"; + + public static final Set MOD_LOADER_MAIN_CLASSES_PACKAGES = Set.of( + "net.minecraftforge", + "net.neoforged", + "top.outlands", // Cleanroom + "net.fabricmc", + "org.quiltmc", + "cpw.mods" + ); + + public static final Set FORGE_OPTIFINE_MAIN = Set.of( + LibraryAnalyzer.VANILLA_MAIN, + LibraryAnalyzer.LAUNCH_WRAPPER_MAIN, + LibraryAnalyzer.MOD_LAUNCHER_MAIN, + LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN, + LibraryAnalyzer.FORGE_BOOTSTRAP_MAIN, + LibraryAnalyzer.NEO_FORGE_BOOTSTRAP_MAIN + ); + + public static final VersionRange FORGE_OPTIFINE_BROKEN_RANGE = VersionNumber.between("48.0.0", "49.0.50"); + + public static final @Unmodifiable List FORGE_TWEAKERS = List.of( + "net.minecraftforge.legacy._1_5_2.LibraryFixerTweaker", // 1.5.2 + "cpw.mods.fml.common.launcher.FMLTweaker", // 1.6.1 ~ 1.7.10 + "net.minecraftforge.fml.common.launcher.FMLTweaker" // 1.8 ~ 1.12.2 + ); + public static final @Unmodifiable List OPTIFINE_TWEAKERS = List.of( + "optifine.OptiFineTweaker", + "optifine.OptiFineForgeTweaker" + ); + public static final String LITELOADER_TWEAKER = "com.mumfrey.liteloader.launch.LiteLoaderTweaker"; +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java index 99e4ab4c600..3935f486b74 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -222,6 +222,8 @@ protected boolean matchLibrary(Library library, List libraries) { } }; + public static final List ALL = List.of(GameComponentType.values()); + private final String patchId; private final @Nullable ModLoaderType modLoaderType; 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 f0919a3b461..4ff27fe4bd2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java @@ -92,6 +92,20 @@ public record Resolved(GameInstanceManifest unresolved, throw new IllegalArgumentException("Standalone manifest cannot inherit from another manifest"); } } + + public boolean isModded() { + String mainClass = launchManifest().mainClass(); + if (mainClass == null || GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(mainClass)) { + return false; + } + + for (String packageName : GameComponentAnalyzer.MOD_LOADER_MAIN_CLASSES_PACKAGES) { + if (mainClass.startsWith(packageName)) + return true; + } + + return false; + } } GameInstanceManifest merge(GameInstanceManifest parent) { From 12799b9f9a5cfd160b8eb0a38d23e2ebd4911a23 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 20:27:59 +0800 Subject: [PATCH 095/199] feat(GameComponentAnalyzer): update component status handling and simplify logic --- .../hmcl/game/GameComponentAnalyzer.java | 34 +++++++++---------- 1 file changed, 17 insertions(+), 17 deletions(-) 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 c2275a88768..300f1b73196 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -18,10 +18,8 @@ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jackhuang.hmcl.util.versioning.VersionRange; -import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; @@ -38,28 +36,30 @@ private static GameComponentAnalyzer analyze( var components = new EnumMap(GameComponentType.class); if (gameVersion != null) { - components.put(GameComponentType.GAME, new Mark(GameComponentType.GAME, gameVersion, Status.CLEAR)); + components.put(GameComponentType.GAME, new Mark(GameComponentType.GAME, gameVersion, true)); + } + + for (GameInstancePatch patch : standaloneManifest.getPatches()) { + if (patch.isHidden() || patch.id() == null) continue; + + @Nullable GameComponentType type = GameComponentType.fromPatchId(patch.id()); + if (type != null) { + components.put(type, new Mark(type, patch.version(), true)); + } } List rawLibraries = launchManifest.getLibraries(); for (Library library : rawLibraries) { for (GameComponentType type : GameComponentType.ALL) { + if (components.containsKey(type)) continue; + if (type.matchLibrary(library, rawLibraries)) { - components.put(type, new Mark(type, type.getComponentVersion(standaloneManifest, library.version()), Status.CLEAR)); + components.put(type, new Mark(type, type.getComponentVersion(standaloneManifest, library.version()), false)); break; } } } - for (GameInstancePatch patch : standaloneManifest.getPatches()) { - if (patch.isHidden() || patch.id() == null) continue; - - @Nullable GameComponentType type = GameComponentType.fromPatchId(patch.id()); - if (type != null) { - components.put(type, new Mark(type, patch.version(), Status.CLEAR)); - } - } - return new GameComponentAnalyzer(standaloneManifest, components); } @@ -91,12 +91,12 @@ private GameComponentAnalyzer(GameInstanceManifest manifest, Map iterator() { + public Iterator iterator() { return components.values().iterator(); } @@ -110,7 +110,7 @@ public enum Status { public record Mark( GameComponentType componentType, @Nullable String version, - Status status + boolean clear ) { } From d1d32e9931935298bf0214503ea3d979e3f11486 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 20:33:04 +0800 Subject: [PATCH 096/199] feat(GameComponentType): add MOD_LOADERS list for filtering mod loader components --- .../org/jackhuang/hmcl/game/HMCLModpackInstallTask.java | 9 ++++----- .../java/org/jackhuang/hmcl/game/GameComponentType.java | 3 +++ .../main/java/org/jackhuang/hmcl/util/SettingsMap.java | 8 +++----- 3 files changed, 10 insertions(+), 10 deletions(-) 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 3fc359f1f27..a7a3a03a5fe 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -19,7 +19,6 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.modpack.MinecraftInstanceTask; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; @@ -90,14 +89,14 @@ public List> getDependents() { public void execute() throws Exception { String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json"); GameInstanceManifest originalManifest = JsonUtils.GSON.fromJson(json, GameInstanceManifest.class).withId(instanceId).withJar(null); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(originalManifest, null); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null); Task libraryTask = Task.supplyAsync(() -> originalManifest); // reinstall libraries // libraries of Forge and OptiFine should be obtained by installation. - for (LibraryAnalyzer.LibraryMark mark : analyzer) { - if (LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(mark.getLibraryId())) + for (GameComponentAnalyzer.Mark mark : analyzer) { + if (mark.componentType() == GameComponentType.GAME) continue; - libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.getLibraryId(), mark.getLibraryVersion())); + libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.componentType().getPatchId(), mark.version())); } dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync)); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java index 3935f486b74..b6ed1e4c712 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -223,6 +223,9 @@ protected boolean matchLibrary(Library library, List libraries) { }; public static final List ALL = List.of(GameComponentType.values()); + public static final List MOD_LOADERS = ALL.stream() + .filter(GameComponentType::isModLoader) + .toList(); private final String patchId; private final @Nullable ModLoaderType modLoaderType; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java index f91329f0aab..cc66267d1be 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/SettingsMap.java @@ -17,8 +17,8 @@ */ package org.jackhuang.hmcl.util; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -91,10 +91,8 @@ public void clear() { /// Returns whether the selected installation includes any non-vanilla component. public boolean isInstallingModdedVersion() { - for (LibraryAnalyzer.LibraryType value : LibraryAnalyzer.LibraryType.values()) { - if (value != LibraryAnalyzer.LibraryType.MINECRAFT - && value.isModLoader() - && get(value.getPatchId()) instanceof RemoteVersion) { + for (GameComponentType value : GameComponentType.MOD_LOADERS) { + if (get(value.getPatchId()) instanceof RemoteVersion) { return true; } } From 1f1eaec54f8b5fcd502aaee0f15ceaecf4d3d62f Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 20:39:31 +0800 Subject: [PATCH 097/199] feat(GameComponentAnalyzer): enhance mod loader support and refactor analyzer usage --- .../hmcl/ui/instances/ModListPage.java | 22 ++++++++----------- .../jackhuang/hmcl/addon/mod/ModManager.java | 13 ++++++----- .../hmcl/game/GameComponentAnalyzer.java | 15 +++++++++++++ 3 files changed, 31 insertions(+), 19 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index 9dae21b535c..f95f745c893 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -21,11 +21,7 @@ import javafx.collections.ObservableList; import javafx.scene.control.Skin; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.addon.mod.ModManager; @@ -147,13 +143,13 @@ private void loadMods(ModManager modManager) { private void updateSupportedLoaders(ModManager modManager) { supportedLoaders.clear(); - LibraryAnalyzer analyzer = modManager.getLibraryAnalyzer(); + GameComponentAnalyzer analyzer = modManager.getComponentAnalyzer(); if (analyzer == null) { Collections.addAll(supportedLoaders, ModLoaderType.values()); return; } - for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { + for (GameComponentType type : GameComponentType.MOD_LOADERS) { if (type.isModLoader() && analyzer.has(type)) { ModLoaderType modLoaderType = type.getModLoaderType(); if (modLoaderType != null) { @@ -165,26 +161,26 @@ private void updateSupportedLoaders(ModManager modManager) { } } - if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && "1.20.1".equals(gameVersion)) { + if (analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) { supportedLoaders.add(ModLoaderType.FORGE); } - if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) { + if (analyzer.has(GameComponentType.QUILT)) { supportedLoaders.add(ModLoaderType.FABRIC); } - if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) { + if (analyzer.has(GameComponentType.LEGACY_FABRIC)) { supportedLoaders.add(ModLoaderType.FABRIC); } - if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) { + if (analyzer.has(GameComponentType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) { supportedLoaders.add(ModLoaderType.FORGE); supportedLoaders.add(ModLoaderType.NEO_FORGE); } // Sinytra Connector - if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE)) - || "1.20.1".equals(gameVersion) && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) { + if (analyzer.has(GameComponentType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE)) + || "1.20.1".equals(gameVersion) && analyzer.has(GameComponentType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) { supportedLoaders.add(ModLoaderType.FABRIC); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java index 3dea12cd887..1c955f6ab57 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java @@ -21,8 +21,9 @@ import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.LocalAddonManager; import org.jackhuang.hmcl.addon.meta.*; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; @@ -64,7 +65,7 @@ private interface ModMetadataReader { } private final HashMap, LocalMod> localMods = new HashMap<>(); - private LibraryAnalyzer analyzer; + private GameComponentAnalyzer analyzer; private boolean loaded = false; @@ -80,7 +81,7 @@ public Path getDirectory() { return instance.getModsDirectory(); } - public LibraryAnalyzer getLibraryAnalyzer() { + public GameComponentAnalyzer getComponentAnalyzer() { return analyzer; } @@ -181,10 +182,10 @@ public void refresh() throws IOException { localFiles.clear(); localMods.clear(); - analyzer = LibraryAnalyzer.analyze(instance.getResolvedManifest(), null); + analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), null); - boolean supportSubfolders = analyzer.has(LibraryAnalyzer.LibraryType.FORGE) - || analyzer.has(LibraryAnalyzer.LibraryType.QUILT); + boolean supportSubfolders = analyzer.has(GameComponentType.FORGE) + || analyzer.has(GameComponentType.QUILT); if (Files.isDirectory(getDirectory())) { try (DirectoryStream modsDirectoryStream = Files.newDirectoryStream(getDirectory())) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java index 300f1b73196..d5d449b1816 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.game; +import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jackhuang.hmcl.util.versioning.VersionRange; @@ -83,6 +84,10 @@ private GameComponentAnalyzer(GameInstanceManifest manifest, Map getModLoaders() { + Set res = EnumSet.noneOf(ModLoaderType.class); + for (GameComponentType type : components.keySet()) { + if (type.getModLoaderType() != null) { + res.add(type.getModLoaderType()); + } + } + return res; + } + @Override public Iterator iterator() { return components.values().iterator(); From af487314558830bf9880385d0775231941b1ebf6 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 20:43:45 +0800 Subject: [PATCH 098/199] feat(GameComponentAnalyzer): replace LibraryAnalyzer references with GameComponentAnalyzer for consistency --- .../download/LaunchManifestPreparation.java | 8 ++----- .../hmcl/download/LibraryAnalyzer.java | 23 +++++++------------ .../hmcl/download/forge/ForgeInstallTask.java | 3 ++- .../hmcl/download/game/GameLibrariesTask.java | 12 ++++------ .../game/GameVerificationFixTask.java | 5 ++-- .../liteloader/LiteLoaderInstallTask.java | 2 +- .../optifine/OptiFineInstallTask.java | 6 ++--- .../hmcl/game/GameComponentAnalyzer.java | 13 +++++------ .../hmcl/game/JavaVersionConstraint.java | 4 +--- .../hmcl/game/LaunchManifestNormalizer.java | 10 ++++---- .../hmcl/launch/LaunchClasspathResolver.java | 7 ++---- .../hmcl/game/DefaultGameInstanceTest.java | 7 +++--- 12 files changed, 40 insertions(+), 60 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java index 27d29b07381..49ee8221eb4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java @@ -17,11 +17,7 @@ */ package org.jackhuang.hmcl.download; -import org.jackhuang.hmcl.game.Argument; -import org.jackhuang.hmcl.game.GameInstanceLibraryBuilder; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.game.StringArgument; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jetbrains.annotations.NotNullByDefault; @@ -71,7 +67,7 @@ public static GameInstanceManifest prepare( private static GameInstanceManifest prepareBootstrapLauncher( GameRepository repository, GameInstanceManifest manifest) { - if (!LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(manifest.mainClass())) { + if (!GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(manifest.mainClass())) { return manifest; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java index fd7bc4b672f..604b357858a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java @@ -93,8 +93,8 @@ public boolean hasModLoader() { } public boolean hasModLauncher() { - return LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()) || manifest.getPatches().stream().anyMatch( - patch -> LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(patch.mainClass()) + return GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()) || manifest.getPatches().stream().anyMatch( + patch -> GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(patch.mainClass()) ); } @@ -435,20 +435,13 @@ public LibraryStatus getStatus() { } } - public static final String VANILLA_MAIN = "net.minecraft.client.main.Main"; - public static final String LAUNCH_WRAPPER_MAIN = "net.minecraft.launchwrapper.Launch"; - public static final String MOD_LAUNCHER_MAIN = "cpw.mods.modlauncher.Launcher"; - public static final String BOOTSTRAP_LAUNCHER_MAIN = "cpw.mods.bootstraplauncher.BootstrapLauncher"; - public static final String FORGE_BOOTSTRAP_MAIN = "net.minecraftforge.bootstrap.ForgeBootstrap"; - public static final String NEO_FORGE_BOOTSTRAP_MAIN = "net.neoforged.fml.startup.Client"; - public static final Set FORGE_OPTIFINE_MAIN = Set.of( - LibraryAnalyzer.VANILLA_MAIN, - LibraryAnalyzer.LAUNCH_WRAPPER_MAIN, - LibraryAnalyzer.MOD_LAUNCHER_MAIN, - LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN, - LibraryAnalyzer.FORGE_BOOTSTRAP_MAIN, - LibraryAnalyzer.NEO_FORGE_BOOTSTRAP_MAIN + GameComponentAnalyzer.VANILLA_MAIN, + GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN, + GameComponentAnalyzer.MOD_LAUNCHER_MAIN, + GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN, + GameComponentAnalyzer.FORGE_BOOTSTRAP_MAIN, + GameComponentAnalyzer.NEO_FORGE_BOOTSTRAP_MAIN ); public static final VersionRange FORGE_OPTIFINE_BROKEN_RANGE = VersionNumber.between("48.0.0", "49.0.50"); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java index bff618bfb83..5409344e908 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.download.forge; import org.jackhuang.hmcl.download.*; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -102,7 +103,7 @@ public void execute() throws IOException, VersionMismatchException, UnsupportedI String originalMainClass = manifest.resolve(dependencyManager.getGameRepository()).mainClass(); if (GameVersionNumber.compare("1.13", remote.getGameVersion()) <= 0) { // Forge 1.13 is not compatible with fabric. - if (!LibraryAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) + if (!GameComponentAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) throw new UnsupportedInstallationException(UNSUPPORTED_LAUNCH_WRAPPER); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java index f466f0fb50e..30e13be0ee8 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 @@ -19,10 +19,7 @@ import org.jackhuang.hmcl.download.AbstractDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.DigestUtils; @@ -167,10 +164,9 @@ public void execute() throws IOException { Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), library); if ("optifine".equals(library.groupId()) && Files.exists(file) && GameVersionNumber.asGameVersion(gameRepository.getGameVersion(manifest).orElse(null)).compareTo("1.20.4") == 0) { - String forgeVersion = LibraryAnalyzer.analyze(manifest, "1.20.4") - .getVersion(LibraryAnalyzer.LibraryType.FORGE) - .orElse(null); - if (forgeVersion != null && LibraryAnalyzer.FORGE_OPTIFINE_BROKEN_RANGE.contains(VersionNumber.asVersion(forgeVersion))) { + @Nullable String forgeVersion = GameComponentAnalyzer.analyze(manifest, "1.20.4") + .getVersion(GameComponentType.FORGE); + if (forgeVersion != null && GameComponentAnalyzer.FORGE_OPTIFINE_BROKEN_RANGE.contains(VersionNumber.asVersion(forgeVersion))) { try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(file)) { Files.deleteIfExists(fs2.getPath("/META-INF/mods.toml")); } catch (IOException e) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java index 54f4cb27165..8e711dde107 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.task.Task; @@ -63,9 +64,9 @@ public GameVerificationFixTask(GameInstance instance, GameVersionNumber gameVers @Override public void execute() throws IOException { Path jar = instance.getInstanceJarFile(); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, gameVersion.toString()); + var analyzer = GameComponentAnalyzer.analyze(manifest, gameVersion.toString()); - if (Files.exists(jar) && gameVersion.compareTo("1.6") < 0 && analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) { + if (Files.exists(jar) && gameVersion.compareTo("1.6") < 0 && analyzer.has(GameComponentAnalyzer.LibraryType.FORGE)) { try (FileSystem fs = CompressingUtils.createWritableZipFileSystem(jar, StandardCharsets.UTF_8)) { Files.deleteIfExists(fs.getPath("META-INF/MOJANG_C.DSA")); Files.deleteIfExists(fs.getPath("META-INF/MOJANG_C.SF")); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java index 985de491c2d..330a7cb4787 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java @@ -69,7 +69,7 @@ public void execute() { remote.getSelfVersion(), 60000, new Arguments().addGameArguments("--tweakClass", "com.mumfrey.liteloader.launch.LiteLoaderTweaker"), - LibraryAnalyzer.LAUNCH_WRAPPER_MAIN, + GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN, Lang.merge(remote.getLibraries(), Collections.singleton(library))) .withLogging(Collections.emptyMap()) // Mods may log in malformed format, causing XML parser to crash. So we suppress using official log4j configuration ); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java index 4918eb5ac7d..b3e3e2fdcd1 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 @@ -124,7 +124,7 @@ public boolean isRelyingOnDependencies() { @Override public void execute() throws Exception { String originalMainClass = manifest.resolve(dependencyManager.getGameRepository()).mainClass(); - if (!LibraryAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) + if (!GameComponentAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) throw new UnsupportedInstallationException(UnsupportedInstallationException.UNSUPPORTED_LAUNCH_WRAPPER); List libraries = new ArrayList<>(4); @@ -194,7 +194,7 @@ public void execute() throws Exception { String buildof = Files.readString(buildofText).trim(); VersionNumber buildofVer = VersionNumber.asVersion(buildof); - if (LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(originalMainClass)) { + if (GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(originalMainClass)) { // OptiFine H1 Pre2+ is compatible with Forge 1.17 if (buildofVer.compareTo("20210924-190833") < 0) { throw new UnsupportedInstallationException(UnsupportedInstallationException.FORGE_1_17_OPTIFINE_H1_PRE2); @@ -212,7 +212,7 @@ public void execute() throws Exception { remote.getSelfVersion(), 10000, new Arguments().addGameArguments("--tweakClass", "optifine.OptiFineTweaker"), - LibraryAnalyzer.LAUNCH_WRAPPER_MAIN, + GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN, libraries )); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java index d5d449b1816..21465a3df40 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.addon.mod.ModLoaderType; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jackhuang.hmcl.util.versioning.VersionRange; import org.jetbrains.annotations.NotNullByDefault; @@ -146,12 +145,12 @@ public record Mark( ); public static final Set FORGE_OPTIFINE_MAIN = Set.of( - LibraryAnalyzer.VANILLA_MAIN, - LibraryAnalyzer.LAUNCH_WRAPPER_MAIN, - LibraryAnalyzer.MOD_LAUNCHER_MAIN, - LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN, - LibraryAnalyzer.FORGE_BOOTSTRAP_MAIN, - LibraryAnalyzer.NEO_FORGE_BOOTSTRAP_MAIN + VANILLA_MAIN, + LAUNCH_WRAPPER_MAIN, + MOD_LAUNCHER_MAIN, + BOOTSTRAP_LAUNCHER_MAIN, + FORGE_BOOTSTRAP_MAIN, + NEO_FORGE_BOOTSTRAP_MAIN ); public static final VersionRange FORGE_OPTIFINE_BROKEN_RANGE = VersionNumber.between("48.0.0", "49.0.50"); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java index 11ac2f57a0f..2fba994afc2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java @@ -30,8 +30,6 @@ import java.util.List; import java.util.Objects; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LAUNCH_WRAPPER_MAIN; - public enum JavaVersionConstraint { VANILLA(true, VersionRange.all(), VersionRange.all()) { @Override @@ -135,7 +133,7 @@ public VersionRange getJavaVersionRange(GameInstanceManifest mani protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { if (version == null) return false; - return super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer) && LAUNCH_WRAPPER_MAIN.equals(version.mainClass()) && + return super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer) && GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(version.mainClass()) && version.getLibraries().stream() .filter(library -> "launchwrapper".equals(library.artifactId())) .anyMatch(library -> VersionNumber.asVersion(library.version()).compareTo(VersionNumber.asVersion("1.13")) < 0); 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 2f7fe8a0b81..f073caac2f0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -60,14 +60,14 @@ public static GameInstanceManifest normalize(GameInstanceManifest manifest) { GameInstanceManifest normalized = uniqueLibraries(manifest); @Nullable String mainClass = normalized.mainClass(); - if (LibraryAnalyzer.LAUNCH_WRAPPER_MAIN.equals(mainClass)) { + if (GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(mainClass)) { normalized = normalizeLaunchWrapper(normalized, true); - if (LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(normalized.mainClass())) { + if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(normalized.mainClass())) { normalized = normalizeModLauncher(normalized); } - } else if (LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(mainClass)) { + } else if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(mainClass)) { normalized = normalizeModLauncher(normalized); - } else if (LibraryAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(mainClass)) { + } else if (GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(mainClass)) { normalized = normalizeBootstrapLauncher(normalized); } @@ -108,7 +108,7 @@ private static GameInstanceManifest normalizeLaunchWrapper( reorderTweakClass); } } else if (analyzer.hasModLauncher()) { - mainClass = LibraryAnalyzer.MOD_LAUNCHER_MAIN; + mainClass = GameComponentAnalyzer.MOD_LAUNCHER_MAIN; for (String optiFineTweaker : LibraryAnalyzer.OPTIFINE_TWEAKERS) { builder.removeTweakClass(optiFineTweaker); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java index a68c08f3f37..6a4356a0b66 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java @@ -18,10 +18,7 @@ package org.jackhuang.hmcl.launch; import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -61,7 +58,7 @@ public static Set resolve( return classpath; } - boolean removeFromClasspath = LibraryAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()); + boolean removeFromClasspath = GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()); @Nullable Path selectedInstallerFile = null; for (Library library : manifest.getLibraries()) { 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 b4b76ea57c5..1f8e29c55f4 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -20,7 +20,6 @@ import org.jackhuang.hmcl.download.DefaultCacheRepository; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LaunchManifestPreparation; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.MojangDownloadProvider; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameVerificationFixTask; @@ -90,7 +89,7 @@ public void testModLauncherNormalizationDoesNotWriteBundledLibraries(@TempDir Pa TestRepository repository = new TestRepository(tempDirectory.resolve("game")); GameInstanceID instanceId = new GameInstanceID("instance"); GameInstanceManifest manifest = new GameInstanceManifest(instanceId) - .withMainClass(LibraryAnalyzer.MOD_LAUNCHER_MAIN) + .withMainClass(GameComponentAnalyzer.MOD_LAUNCHER_MAIN) .withLibraries(List.of( new Library(new Artifact("net.minecraftforge", "forge", "1.0")), new Library(new Artifact("optifine", "OptiFine", "1.0")))); @@ -118,7 +117,7 @@ public void testLaunchClasspathSelectsInstalledOptiFine(@TempDir Path tempDirect Library optiFineLaunchWrapper = new Library( new Artifact("optifine", "launchwrapper-of", "2.0")); GameInstanceManifest manifest = new GameInstanceManifest(instanceId) - .withMainClass(LibraryAnalyzer.LAUNCH_WRAPPER_MAIN) + .withMainClass(GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN) .withLibraries(List.of(forge, optiFine, optiFineLaunchWrapper)); GameInstanceManifest launchManifest = repository.publish(instanceId, manifest) .getResolvedManifest() @@ -160,7 +159,7 @@ public void testModLauncherClasspathOmitsInstalledOptiFine(@TempDir Path tempDir Library forge = new Library(new Artifact("net.minecraftforge", "forge", "1.0")); Library optiFine = new Library(new Artifact("optifine", "OptiFine", "1.0")); GameInstanceManifest manifest = new GameInstanceManifest(instanceId) - .withMainClass(LibraryAnalyzer.MOD_LAUNCHER_MAIN) + .withMainClass(GameComponentAnalyzer.MOD_LAUNCHER_MAIN) .withLibraries(List.of(forge, optiFine)); GameInstanceManifest launchManifest = repository.publish(instanceId, manifest) .getResolvedManifest() From 10685edf3db351595a5255c04048dd5ffb73fe27 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 20:50:59 +0800 Subject: [PATCH 099/199] feat(GameVerificationFixTask): replace LibraryAnalyzer with GameComponentAnalyzer for improved consistency --- .../jackhuang/hmcl/game/LauncherHelper.java | 30 ++++---- .../game/GameVerificationFixTask.java | 4 +- .../hmcl/game/JavaVersionConstraint.java | 68 +++++++++---------- 3 files changed, 50 insertions(+), 52 deletions(-) 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 8694f73d4a3..4da6de1553b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -25,7 +25,6 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.LaunchManifestPreparation; import org.jackhuang.hmcl.download.game.*; import org.jackhuang.hmcl.java.JavaManager; @@ -438,8 +437,8 @@ public void onStop(boolean success, TaskExecutor executor) { } private static Task checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) { - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, gameInstance.getVersion().toString()); - GameVersionNumber gameVersion = GameVersionNumber.asGameVersion(analyzer.getVersion(LibraryAnalyzer.LibraryType.MINECRAFT)); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion().toString()); + GameVersionNumber gameVersion = gameInstance.getVersion(); Task getJavaTask = Task.supplyAsync(() -> { try { @@ -470,9 +469,9 @@ private static Task checkGameState(HMCLGameInstance gameInstance, G int targetJavaVersionMajor = Integer.parseInt(setting.getInheritable(GameSettings::customJavaVersionProperty)); GameJavaVersion minimumJavaVersion = null; if (gameVersion.compareTo("1.12.2") == 0) { - Optional cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM); - if (cleanroomVersion.isPresent()) { - minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get()); + @Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM); + if (cleanroomVersion != null) { + minimumJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion); } } @@ -494,9 +493,9 @@ private static Task checkGameState(HMCLGameInstance gameInstance, G } } else { if (gameVersion.compareTo("1.12.2") == 0) { - Optional cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM); - if (cleanroomVersion.isPresent()) { - targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion.get()); + @Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM); + if (cleanroomVersion != null) { + targetJavaVersion = GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion); } } @@ -568,10 +567,9 @@ private static Task checkGameState(HMCLGameInstance gameInstance, G } else { GameJavaVersion gameJavaVersion; if (violatedMandatoryConstraints.contains(JavaVersionConstraint.CLEANROOM)) { - String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM) - .orElse(""); + @Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM); - gameJavaVersion = !cleanroomVersion.isEmpty() + gameJavaVersion = cleanroomVersion != null ? GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion) : GameJavaVersion.JAVA_21; } else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.GAME_JSON)) @@ -652,7 +650,7 @@ else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA)) break; case MODDED_JAVA_16: // Minecraft<=1.17.1+Forge[37.0.0,37.0.60) not compatible with Java 17 - String forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE).orElse(null); + @Nullable String forgePatchVersion = analyzer.getVersion(GameComponentType.FORGE); if (forgePatchVersion != null && VersionNumber.compare(forgePatchVersion, "37.0.60") < 0) suggestions.add(i18n("launch.advice.forge37_0_60")); else @@ -665,8 +663,8 @@ else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA)) suggestions.add(i18n("launch.advice.modded_java", 21, gameVersion)); break; case CLEANROOM: { - String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse(""); - if (!cleanroomVersion.isEmpty()) + @Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM); + if (cleanroomVersion != null) suggestions.add(i18n("launch.advice.cleanroom", GameJavaVersion.getCleanroomJavaVersion(cleanroomVersion).majorVersion(), cleanroomVersion)); else suggestions.add(i18n("launch.advice.cleanroom", 21, "")); @@ -695,7 +693,7 @@ else if (violatedMandatoryConstraints.contains(JavaVersionConstraint.VANILLA)) suggestions.add(i18n("launch.advice.not_enough_space", totalMemorySizeMB)); } - VersionNumber forgeVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE) + VersionNumber forgeVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE)) .map(VersionNumber::asVersion) .orElse(null); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java index 8e711dde107..d31871026ea 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java @@ -17,8 +17,8 @@ */ package org.jackhuang.hmcl.download.game; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.task.Task; @@ -66,7 +66,7 @@ public void execute() throws IOException { Path jar = instance.getInstanceJarFile(); var analyzer = GameComponentAnalyzer.analyze(manifest, gameVersion.toString()); - if (Files.exists(jar) && gameVersion.compareTo("1.6") < 0 && analyzer.has(GameComponentAnalyzer.LibraryType.FORGE)) { + if (Files.exists(jar) && gameVersion.compareTo("1.6") < 0 && analyzer.has(GameComponentType.FORGE)) { try (FileSystem fs = CompressingUtils.createWritableZipFileSystem(jar, StandardCharsets.UTF_8)) { Files.deleteIfExists(fs.getPath("META-INF/MOJANG_C.DSA")); Files.deleteIfExists(fs.getPath("META-INF/MOJANG_C.SF")); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java index 2fba994afc2..dc604f70369 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/JavaVersionConstraint.java @@ -17,7 +17,6 @@ */ package org.jackhuang.hmcl.game; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.platform.Architecture; @@ -29,17 +28,18 @@ import java.util.List; import java.util.Objects; +import java.util.Optional; public enum JavaVersionConstraint { VANILLA(true, VersionRange.all(), VersionRange.all()) { @Override - protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { // Give priority to the Java version requirements specified in the version JSON return version == null || version.javaVersion() == null; } @Override - public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, LibraryAnalyzer analyzer) { + public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, GameComponentAnalyzer analyzer) { GameJavaVersion minimumJavaVersion = GameJavaVersion.getMinimumJavaVersion(gameVersionNumber); return minimumJavaVersion == null || java.getParsedVersion() >= minimumJavaVersion.majorVersion(); } @@ -48,14 +48,14 @@ public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManife GAME_JSON(true, VersionRange.all(), VersionRange.all()) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { if (version == null) return false; // We only checks for 1.7.10 and above, since 1.7.2 with Forge can only run on Java 7, but it is recorded Java 8 in game json, which is not correct. return gameVersionNumber.compareTo("1.7.10") >= 0 && version.javaVersion() != null; } @Override - public VersionRange getJavaVersionRange(GameInstanceManifest manifest, LibraryAnalyzer analyzer) { + public VersionRange getJavaVersionRange(GameInstanceManifest manifest, GameComponentAnalyzer analyzer) { String javaVersion; if (Objects.requireNonNull(manifest.javaVersion()).majorVersion() >= 9) { javaVersion = "" + manifest.javaVersion().majorVersion(); @@ -69,57 +69,57 @@ public VersionRange getJavaVersionRange(GameInstanceManifest mani MODDED_JAVA_7(false, GameVersionNumber.atMost("1.7.2"), VersionNumber.atMost("1.7.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.FORGE) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } }, MODDED_JAVA_8(false, GameVersionNumber.between("1.7.10", "1.16.999"), VersionNumber.between("1.8", "1.8.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.FORGE) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } }, MODDED_JAVA_16(false, GameVersionNumber.between("1.17", "1.17.999"), VersionNumber.between("16", "16.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.FORGE) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } }, MODDED_JAVA_17(false, GameVersionNumber.between("1.18", "1.20.4"), VersionNumber.between("17", "17.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.FORGE) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } }, MODDED_JAVA_21(false, GameVersionNumber.atLeast("1.20.5"), VersionNumber.between("21", "21.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.FORGE) + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.FORGE) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } }, CLEANROOM(true, GameVersionNumber.between("1.12.2", "1.12.999"), VersionRange.all()) { @Override - protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { - return analyzer != null && analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM) + protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { + return analyzer != null && analyzer.has(GameComponentType.CLEANROOM) && super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } @Override - public VersionRange getJavaVersionRange(GameInstanceManifest manifest, LibraryAnalyzer analyzer) { - if (analyzer == null || !analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) + public VersionRange getJavaVersionRange(GameInstanceManifest manifest, GameComponentAnalyzer analyzer) { + if (analyzer == null || !analyzer.has(GameComponentType.CLEANROOM)) return VersionRange.all(); - String cleanroomVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.CLEANROOM).orElse(""); - if (cleanroomVersion.isEmpty()) + @Nullable String cleanroomVersion = analyzer.getVersion(GameComponentType.CLEANROOM); + if (cleanroomVersion == null) return VersionRange.all(); else return VersionNumber.atLeast( @@ -131,7 +131,7 @@ public VersionRange getJavaVersionRange(GameInstanceManifest mani LAUNCH_WRAPPER(true, GameVersionNumber.atMost("1.12.999"), VersionNumber.atMost("1.8.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { if (version == null) return false; return super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer) && GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(version.mainClass()) && version.getLibraries().stream() @@ -146,15 +146,15 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul VANILLA_LINUX_JAVA_8(true, GameVersionNumber.atMost("1.12.999"), VersionNumber.atMost("1.8.999")) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { return OperatingSystem.CURRENT_OS == OperatingSystem.LINUX && Architecture.SYSTEM_ARCH == Architecture.X86_64 && (java == null || java.getArchitecture() == Architecture.X86_64) - && (analyzer == null || !analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)); + && (analyzer == null || !analyzer.has(GameComponentType.CLEANROOM)); } @Override - public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, LibraryAnalyzer analyzer) { + public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, GameComponentAnalyzer analyzer) { return java.getArchitecture() != Architecture.X86_64 || super.checkJava(gameVersionNumber, version, java, analyzer); } }, @@ -162,7 +162,7 @@ public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManife VANILLA_X86(false, VersionRange.all(), VersionRange.all()) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { if (java == null || java.getArchitecture() != Architecture.ARM64) return false; @@ -173,7 +173,7 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul } @Override - public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, LibraryAnalyzer analyzer) { + public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, GameComponentAnalyzer analyzer) { return java.getArchitecture().isX86(); } }, @@ -181,10 +181,10 @@ public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManife MODLAUNCHER_8(false, GameVersionNumber.between("1.16.3", "1.17.1"), VersionRange.all()) { @Override protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { if (version == null || java == null || analyzer == null || !super.appliesToVersionImpl(gameVersionNumber, version, java, analyzer)) return false; - VersionNumber forgePatchVersion = analyzer.getVersion(LibraryAnalyzer.LibraryType.FORGE) + VersionNumber forgePatchVersion = Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE)) .map(VersionNumber::asVersion) .orElse(null); if (forgePatchVersion == null) { @@ -205,7 +205,7 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul } @Override - public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, LibraryAnalyzer analyzer) { + public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, GameComponentAnalyzer analyzer) { int parsedJavaVersion = java.getParsedVersion(); if (parsedJavaVersion > 17) { return false; @@ -241,18 +241,18 @@ public VersionRange getGameVersionRange() { return gameVersionRange; } - public VersionRange getJavaVersionRange(GameInstanceManifest manifest, LibraryAnalyzer analyzer) { + public VersionRange getJavaVersionRange(GameInstanceManifest manifest, GameComponentAnalyzer analyzer) { return javaVersionRange; } public final boolean appliesToVersion(@Nullable GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, GameComponentAnalyzer analyzer) { return gameVersionRange.contains(gameVersionNumber) && appliesToVersionImpl(gameVersionNumber, version, java, analyzer); } protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nullable GameInstanceManifest version, - @Nullable JavaRuntime java, @Nullable LibraryAnalyzer analyzer) { + @Nullable JavaRuntime java, @Nullable GameComponentAnalyzer analyzer) { GameJavaVersion gameJavaVersion; if (version == null || (gameJavaVersion = version.javaVersion()) == null) { return true; @@ -269,7 +269,7 @@ protected boolean appliesToVersionImpl(GameVersionNumber gameVersionNumber, @Nul } @SuppressWarnings("BooleanMethodIsAlwaysInverted") - public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, LibraryAnalyzer analyzer) { + public boolean checkJava(GameVersionNumber gameVersionNumber, GameInstanceManifest version, JavaRuntime java, GameComponentAnalyzer analyzer) { return getJavaVersionRange(version, analyzer).contains(java.getVersionNumber()); } From 3c17b697fbf74d886df512cefbbf0ca7a661013b Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:01:05 +0800 Subject: [PATCH 100/199] feat(DefaultDependencyManager): replace LibraryAnalyzer with GameComponentAnalyzer for improved mod support --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 30 +++++++------------ .../download/DefaultDependencyManager.java | 12 ++++---- .../hmcl/game/GameComponentAnalyzer.java | 15 ++++++++++ .../hmcl/game/LaunchManifestNormalizer.java | 24 ++++++--------- 4 files changed, 41 insertions(+), 40 deletions(-) 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 7ce6c49d9d0..2283dcd1998 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -24,7 +24,7 @@ import javafx.beans.property.ReadOnlyObjectPropertyBase; import javafx.scene.image.Image; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.java.JavaRuntime; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.modpack.ModpackProvider; @@ -467,22 +467,14 @@ private Image computeIconImage() { GameInstanceManifest.Resolved resolvedManifest = getResolvedManifest(); if (resolvedManifest.isModded()) { - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, null); - if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) - return GameInstanceIconType.FABRIC.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) - return GameInstanceIconType.QUILT.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) - return GameInstanceIconType.LEGACY_FABRIC.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) - return GameInstanceIconType.NEO_FORGE.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) - return GameInstanceIconType.FORGE.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) - return GameInstanceIconType.CLEANROOM.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) - return GameInstanceIconType.CHICKEN.getIcon(); - else if (analyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedManifest, null); + for (ModLoaderType type : ModLoaderType.values()) { + if (analyzer.has(type)) { + return GameInstanceIconType.getIconType(type).getIcon(); + } + } + + if (analyzer.has(GameComponentType.OPTIFINE)) return GameInstanceIconType.OPTIFINE.getIcon(); } @@ -711,8 +703,8 @@ private static LoadResult loadGameSettingsFile(Path file) { case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: " + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> - LOG.warning("Unsupported instance game settings schema. Expected: " - + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); + LOG.warning("Unsupported instance game settings schema. Expected: " + + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { } } 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 5e1f9f9222c..d9ae4a6c3b9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -34,6 +34,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -134,21 +135,20 @@ public Task checkPatchCompletionAsync( GameInstanceManifest original = instance.getManifest(); GameInstanceManifest.Resolved resolvedInstanceManifest = instance.getResolvedManifest(); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedInstanceManifest, gameVersion); - for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedInstanceManifest, gameVersion); + for (GameComponentType type : GameComponentType.values()) { if (!analyzer.has(type)) continue; - if (type == LibraryAnalyzer.LibraryType.OPTIFINE) { - String optifinePatchVersion = analyzer.getVersion(type) - .map(optifineVersion -> { + if (type == GameComponentType.OPTIFINE) { + String optifinePatchVersion = Optional.ofNullable(analyzer.getVersion(type)) .map(optifineVersion -> { Matcher matcher = Pattern.compile("^([0-9.]+)_(?HD_.+)$").matcher(optifineVersion); return matcher.find() ? matcher.group("optifine") : optifineVersion; }) .orElseGet(() -> resolvedInstanceManifest.standaloneManifest().getPatches().stream() .filter(patch -> "optifine".equals(patch.id())) .findAny() - .map(gameInstancePatch -> gameInstancePatch.version()) + .map(GameInstancePatch::version) .orElse(null)); boolean needsReInstallation = manifest.getLibraries().stream() 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 21465a3df40..2c740102c32 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -87,6 +87,21 @@ public boolean has(GameComponentType type) { return components.containsKey(type); } + public boolean has(ModLoaderType type) { + for (GameComponentType componentType : components.keySet()) { + if (componentType.getModLoaderType() == type) { + return true; + } + } + return false; + } + + public boolean hasModLauncher() { + return GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()) || manifest.getPatches().stream().anyMatch( + patch -> GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(patch.mainClass()) + ); + } + public @Nullable String getVersion(GameComponentType type) { Mark mark = components.get(type); return mark != null ? mark.version() : null; 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 f073caac2f0..77e8ca4d533 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -28,12 +28,6 @@ import java.util.HashMap; import java.util.List; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.BOOTSTRAP_LAUNCHER; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.FORGE; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.LITELOADER; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.NEO_FORGE; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.OPTIFINE; - /// Normalizes a structurally resolved manifest into the stable view consumed by launch-time code. /// /// Normalization depends only on manifest content. Filesystem-dependent compatibility adjustments @@ -82,13 +76,13 @@ public static GameInstanceManifest normalize(GameInstanceManifest manifest) { private static GameInstanceManifest normalizeLaunchWrapper( GameInstanceManifest manifest, boolean reorderTweakClass) { - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); @Nullable String mainClass = null; // Forge installers may replace the complete argument list, so compatible tweakers must be // restored in deterministic order. - if (analyzer.has(LITELOADER) && !analyzer.hasModLauncher()) { + if (analyzer.has(GameComponentType.LITELOADER) && !analyzer.hasModLauncher()) { builder.replaceTweakClass( LibraryAnalyzer.LITELOADER_TWEAKER, LibraryAnalyzer.LITELOADER_TWEAKER, @@ -98,8 +92,8 @@ private static GameInstanceManifest normalizeLaunchWrapper( builder.removeTweakClass(LibraryAnalyzer.LITELOADER_TWEAKER); } - if (analyzer.has(OPTIFINE)) { - if (!analyzer.has(LITELOADER) && !analyzer.has(FORGE)) { + if (analyzer.has(GameComponentType.OPTIFINE)) { + if (!analyzer.has(GameComponentType.LITELOADER) && !analyzer.has(GameComponentType.FORGE)) { if (builder.hasTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[1])) { builder.replaceTweakClass( LibraryAnalyzer.OPTIFINE_TWEAKERS[1], @@ -125,7 +119,7 @@ private static GameInstanceManifest normalizeLaunchWrapper( } } - boolean hasForge = analyzer.has(FORGE); + boolean hasForge = analyzer.has(GameComponentType.FORGE); boolean hasModLauncher = analyzer.hasModLauncher(); for (String forgeTweaker : LibraryAnalyzer.FORGE_TWEAKERS) { if (!hasForge) { @@ -148,8 +142,8 @@ private static GameInstanceManifest normalizeLaunchWrapper( /// @param manifest the resolved manifest /// @return the repaired manifest private static GameInstanceManifest normalizeModLauncher(GameInstanceManifest manifest) { - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); - if (!analyzer.has(FORGE) || !analyzer.has(OPTIFINE)) { + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + if (!analyzer.has(GameComponentType.FORGE) || !analyzer.has(GameComponentType.OPTIFINE)) { return manifest; } @@ -186,11 +180,11 @@ private static GameInstanceManifest normalizeModLauncher(GameInstanceManifest ma /// @return the repaired manifest private static GameInstanceManifest normalizeBootstrapLauncher(GameInstanceManifest manifest) { LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); - if (!analyzer.has(FORGE) && !analyzer.has(NEO_FORGE)) { + if (!analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && !analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) { return manifest; } - if (analyzer.getVersion(BOOTSTRAP_LAUNCHER) + if (analyzer.getVersion(LibraryAnalyzer.LibraryType.BOOTSTRAP_LAUNCHER) .filter(version -> VersionNumber.compare(version, "0.1.17") >= 0) .isEmpty()) { return manifest; From 2f594e589f3da4d115f830238eae58833c85c29d Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:11:47 +0800 Subject: [PATCH 101/199] feat(InstallerItem): replace LibraryAnalyzer with GameComponentType for improved component handling --- .../hmcl/setting/GameInstanceIconType.java | 18 ++++++ .../jackhuang/hmcl/ui/GameCrashWindow.java | 21 +++---- .../org/jackhuang/hmcl/ui/InstallerItem.java | 58 ++++++++----------- .../ui/download/AbstractInstallersPage.java | 20 +++---- .../ui/download/AdditionalInstallersPage.java | 10 ++-- 5 files changed, 66 insertions(+), 61 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameInstanceIconType.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameInstanceIconType.java index 369bd815ec1..ad7149fdbee 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameInstanceIconType.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GameInstanceIconType.java @@ -19,7 +19,9 @@ import javafx.scene.image.Image; import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.ui.FXUtils; +import org.jetbrains.annotations.Nullable; public enum GameInstanceIconType { DEFAULT("/assets/img/grass.png"), @@ -54,6 +56,22 @@ public static GameInstanceIconType getIconType(ModLoaderType modLoaderType) { }; } + public static @Nullable GameInstanceIconType getIconType(GameComponentType componentType) { + return switch (componentType) { + case GAME -> GameInstanceIconType.GRASS; + case FABRIC, FABRIC_API -> GameInstanceIconType.FABRIC; + case LEGACY_FABRIC, LEGACY_FABRIC_API -> GameInstanceIconType.LEGACY_FABRIC; + case FORGE -> GameInstanceIconType.FORGE; + case CLEANROOM -> GameInstanceIconType.CLEANROOM; + case LITELOADER -> GameInstanceIconType.CHICKEN; + case OPTIFINE -> GameInstanceIconType.OPTIFINE; + case QUILT, QUILT_API -> GameInstanceIconType.QUILT; + case NEO_FORGE -> GameInstanceIconType.NEO_FORGE; + default -> null; + }; + } + + private final String resourceUrl; GameInstanceIconType(String resourceUrl) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index dda4858a7f2..18488d36eb5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -35,7 +35,6 @@ import javafx.stage.Stage; import kala.encdet.EncodingDetector; import org.jackhuang.hmcl.Metadata; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.launch.ProcessListener; import org.jackhuang.hmcl.setting.StyleSheets; @@ -77,7 +76,7 @@ public class GameCrashWindow extends Stage { private final String memory; private final String total_memory; private final String java; - private final LibraryAnalyzer analyzer; + private final GameComponentAnalyzer analyzer; private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown"))); private final BooleanProperty loading = new SimpleBooleanProperty(); private final TextFlow feedbackTextFlow = new TextFlow(); @@ -98,7 +97,7 @@ public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType e this.gameInstance = gameInstance; this.launchOptions = launchOptions; this.logs = logs; - this.analyzer = LibraryAnalyzer.analyze(gameInstance.getResolvedManifest(), gameInstance.getVersion().toString()); + this.analyzer = GameComponentAnalyzer.analyze(gameInstance.getResolvedManifest(), gameInstance.getVersion().toString()); memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-"); @@ -379,15 +378,13 @@ private final class View extends VBox { moddedPane.setPadding(new Insets(8)); moddedPane.setAlignment(Pos.CENTER_LEFT); - for (LibraryAnalyzer.LibraryType type : LibraryAnalyzer.LibraryType.values()) { - if (!type.getPatchId().isEmpty()) { - analyzer.getVersion(type).ifPresent(ver -> { - TwoLineListItem item = new TwoLineListItem(); - item.getStyleClass().setAll("two-line-item-second-large"); - item.setTitle(i18n("install.installer." + type.getPatchId())); - item.setSubtitle(ver); - moddedPane.getChildren().add(item); - }); + for (GameComponentAnalyzer.Mark mark : analyzer) { + if (mark.version() != null) { + TwoLineListItem item = new TwoLineListItem(); + item.getStyleClass().setAll("two-line-item-second-large"); + item.setTitle(i18n("install.installer." + mark.componentType().getPatchId())); + item.setSubtitle(mark.version()); + moddedPane.getChildren().add(item); } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java index 5984f409279..164acba67bb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java @@ -34,7 +34,7 @@ import javafx.scene.control.SkinBase; import javafx.scene.input.MouseButton; import javafx.scene.layout.*; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.ui.construct.ImageContainer; import org.jackhuang.hmcl.ui.construct.RipplerContainer; @@ -46,13 +46,13 @@ import java.util.Map; import java.util.Set; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; /** * @author huangyuhui */ public class InstallerItem extends Control { + private final GameComponentType type; private final String id; private final GameInstanceIconType iconType; private final Style style; @@ -83,26 +83,16 @@ public enum Style { CARD, } - public InstallerItem(LibraryAnalyzer.LibraryType id, Style style) { - this(id.getPatchId(), style); - } - public InstallerItem(String id, Style style) { - this.id = id; + public InstallerItem(GameComponentType type, Style style) { + this.type = type; + this.id = type.getPatchId(); this.style = style; + this.iconType = GameInstanceIconType.getIconType(type); + } - iconType = switch (id) { - case "game" -> GameInstanceIconType.GRASS; - case "fabric", "fabric-api" -> GameInstanceIconType.FABRIC; - case "legacyfabric", "legacyfabric-api" -> GameInstanceIconType.LEGACY_FABRIC; - case "forge" -> GameInstanceIconType.FORGE; - case "cleanroom" -> GameInstanceIconType.CLEANROOM; - case "liteloader" -> GameInstanceIconType.CHICKEN; - case "optifine" -> GameInstanceIconType.OPTIFINE; - case "quilt", "quilt-api" -> GameInstanceIconType.QUILT; - case "neoforge" -> GameInstanceIconType.NEO_FORGE; - default -> null; - }; + public GameComponentType getComponentType() { + return type; } public String getLibraryId() { @@ -176,18 +166,18 @@ private void mutualIncompatible(Map> incompati } public InstallerItemGroup(GameVersionNumber gameVersion, Style style) { - game = new InstallerItem(MINECRAFT, style); - InstallerItem fabric = new InstallerItem(FABRIC, style); - InstallerItem fabricApi = new InstallerItem(FABRIC_API, style); - InstallerItem forge = new InstallerItem(FORGE, style); - InstallerItem cleanroom = new InstallerItem(CLEANROOM, style); - InstallerItem legacyfabric = new InstallerItem(LEGACY_FABRIC, style); - InstallerItem legacyfabricApi = new InstallerItem(LEGACY_FABRIC_API, style); - InstallerItem neoForge = new InstallerItem(NEO_FORGE, style); - InstallerItem liteLoader = new InstallerItem(LITELOADER, style); - InstallerItem optiFine = new InstallerItem(OPTIFINE, style); - InstallerItem quilt = new InstallerItem(QUILT, style); - InstallerItem quiltApi = new InstallerItem(QUILT_API, style); + game = new InstallerItem(GameComponentType.GAME, style); + InstallerItem fabric = new InstallerItem(GameComponentType.FABRIC, style); + InstallerItem fabricApi = new InstallerItem(GameComponentType.FABRIC_API, style); + InstallerItem forge = new InstallerItem(GameComponentType.FORGE, style); + InstallerItem cleanroom = new InstallerItem(GameComponentType.CLEANROOM, style); + InstallerItem legacyfabric = new InstallerItem(GameComponentType.LEGACY_FABRIC, style); + InstallerItem legacyfabricApi = new InstallerItem(GameComponentType.LEGACY_FABRIC_API, style); + InstallerItem neoForge = new InstallerItem(GameComponentType.NEO_FORGE, style); + InstallerItem liteLoader = new InstallerItem(GameComponentType.LITELOADER, style); + InstallerItem optiFine = new InstallerItem(GameComponentType.OPTIFINE, style); + InstallerItem quilt = new InstallerItem(GameComponentType.QUILT, style); + InstallerItem quiltApi = new InstallerItem(GameComponentType.QUILT_API, style); Map> incompatibleMap = new HashMap<>(); mutualIncompatible(incompatibleMap, forge, fabric, quilt, neoForge, cleanroom, legacyfabric); @@ -217,7 +207,7 @@ public InstallerItemGroup(GameVersionNumber gameVersion, Style style) { for (InstallerItem other : incompatibleItems) { InstalledState otherVersion = other.versionProperty.get(); if (otherVersion != null) { - return new IncompatibleState(other.id, otherVersion.version); + return new IncompatibleState(other.type.getPatchId(), otherVersion.version); } } @@ -309,7 +299,7 @@ private static final class InstallerItemSkin extends SkinBase { nameLabel.getStyleClass().add("installer-item-name"); nameLabel.setMouseTransparent(true); pane.getChildren().add(nameLabel); - nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.id) ? i18n("install.installer." + control.id) : control.id); + nameLabel.textProperty().set(I18n.hasKey("install.installer." + control.type.getPatchId()) ? i18n("install.installer." + control.type.getPatchId()) : control.type.getPatchId()); HBox.setMargin(nameLabel, new Insets(0, 4, 0, 4)); Label statusLabel = new Label(); @@ -355,7 +345,7 @@ private static final class InstallerItemSkin extends SkinBase { pane.getChildren().add(buttonsContainer); JFXButton removeButton = FXUtils.newToggleButton4(SVG.CLOSE); - if (control.id.equals(MINECRAFT.getPatchId())) { + if (control.type == GameComponentType.GAME) { removeButton.setVisible(false); } else { removeButton.visibleProperty().bind(Bindings.createBooleanBinding(() -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java index 93245c07b2b..8003520fe78 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java @@ -30,7 +30,7 @@ import javafx.scene.layout.Priority; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.ui.Controllers; import org.jackhuang.hmcl.ui.FXUtils; @@ -62,15 +62,15 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D this.group = new InstallerItem.InstallerItemGroup(GameVersionNumber.asGameVersion(gameVersion), getInstallerItemStyle()); for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); - if (libraryId.equals(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())) continue; + GameComponentType type = library.getComponentType(); + if (type == GameComponentType.GAME) continue; library.setOnInstall(() -> { if (!Boolean.TRUE.equals(state().getShownTips().get(FABRIC_QUILT_API_TIP)) - && (LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId().equals(libraryId) - || LibraryAnalyzer.LibraryType.QUILT_API.getPatchId().equals(libraryId) - || LibraryAnalyzer.LibraryType.LEGACY_FABRIC_API.getPatchId().equals(libraryId))) { + && (type == GameComponentType.FABRIC_API + || type == GameComponentType.QUILT_API + || type == GameComponentType.LEGACY_FABRIC_API)) { Controllers.dialog(new MessageDialogPane.Builder( - i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + libraryId)), + i18n("install.installer.fabric-quilt-api.warning", i18n("install.installer." + type.getPatchId())), i18n("message.warning"), MessageDialogPane.MessageType.WARNING ).ok(null).addCancel(i18n("button.do_not_show_again"), () -> state().getShownTips().put(FABRIC_QUILT_API_TIP, true)).build()); @@ -80,16 +80,16 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D controller.onNext( new VersionsPage( controller, - i18n("install.installer.choose", i18n("install.installer." + libraryId)), + i18n("install.installer.choose", i18n("install.installer." + type.getPatchId())), gameVersion, downloadProvider, - libraryId, + type.getPatchId(), () -> controller.onPrev(false, Navigation.NavigationDirection.PREVIOUS) ), Navigation.NavigationDirection.NEXT ); }); library.setOnRemove(() -> { - controller.getSettings().remove(libraryId); + controller.getSettings().remove(type.getPatchId()); reload(); }); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java index 835b9d54b80..536d95dfba0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java @@ -21,8 +21,9 @@ import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.ui.InstallerItem; @@ -32,7 +33,6 @@ import java.util.Optional; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; class AdditionalInstallersPage extends AbstractInstallersPage { @@ -81,15 +81,15 @@ private String getVersion(String id) { @Override protected void reload() { GameInstanceManifest.Resolved resolvedManifest = repository.resolve(manifest); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(resolvedManifest, repository.getGameVersion(manifest).orElse(null)); - String game = analyzer.getVersion(MINECRAFT).orElse(null); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedManifest, repository.getGameVersion(manifest).orElse(null)); + String game = analyzer.getVersion(GameComponentType.GAME); String currentGameVersion = Lang.nonNull(getVersion("game"), game); boolean compatible = true; for (InstallerItem library : group.getLibraries()) { String libraryId = library.getLibraryId(); - String version = analyzer.getVersion(libraryId).orElse(null); + String version = analyzer.getVersion(library.getComponentType()); String libraryVersion = Lang.requireNonNullElse(getVersion(libraryId), version); boolean alreadyInstalled = version != null && !(controller.getSettings().get(libraryId) instanceof UpdateInstallerWizardProvider.RemoveVersionAction); if (!"game".equals(libraryId) && currentGameVersion != null && !currentGameVersion.equals(game) && getVersion(libraryId) == null && alreadyInstalled) { From 9ce42e39884569b04d07ee324db1572c85bf16be Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:14:40 +0800 Subject: [PATCH 102/199] feat(CleanroomInstallTask): replace LibraryAnalyzer with GameComponentType for improved patch handling --- .../cleanroom/CleanroomInstallTask.java | 10 ++++++---- .../cleanroom/CleanroomRemoteVersion.java | 4 ++-- .../download/fabric/FabricAPIRemoteVersion.java | 4 ++-- .../jackhuang/hmcl/game/GameInstancePatch.java | 17 +++++++++++++---- 4 files changed, 23 insertions(+), 12 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java index a7d7ff82733..92dd874bf46 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java @@ -18,11 +18,11 @@ package org.jackhuang.hmcl.download.cleanroom; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.UnsupportedInstallationException; import org.jackhuang.hmcl.download.VersionMismatchException; import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile; import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -113,9 +113,11 @@ public Collection> getDependencies() { @Override public void execute() throws IOException, VersionMismatchException, UnsupportedInstallationException { if (selfVersion == null) { - task = new ForgeNewInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer).thenApplyAsync((version) -> version.withId(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId())); + task = new ForgeNewInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer) + .thenApplyAsync((version) -> version.withId(GameComponentType.CLEANROOM)); } else { - task = new ForgeNewInstallTask(dependencyManager, manifest, selfVersion, installer).thenApplyAsync((version) -> version.withId(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId())); + task = new ForgeNewInstallTask(dependencyManager, manifest, selfVersion, installer) + .thenApplyAsync((version) -> version.withId(GameComponentType.CLEANROOM)); } } @@ -125,7 +127,7 @@ public static Task install(DefaultDependencyManager dependenc try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); - if (LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId().equals(installProfile.get("profile"))) { + if (GameComponentType.CLEANROOM.getPatchId().equals(installProfile.get("profile"))) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.get().equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomRemoteVersion.java index 3f88ef351cd..3611eb70fc1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomRemoteVersion.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.download.cleanroom; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -29,7 +29,7 @@ public class CleanroomRemoteVersion extends RemoteVersion { public CleanroomRemoteVersion(String gameVersion, String selfVersion, Instant releaseDate, List url) { - super(LibraryAnalyzer.LibraryType.CLEANROOM.getPatchId(), gameVersion, selfVersion, releaseDate, url); + super(GameComponentType.CLEANROOM.getPatchId(), gameVersion, selfVersion, releaseDate, url); } @Override 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 7458b3f964d..fad87dfbba4 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,8 +18,8 @@ package org.jackhuang.hmcl.download.fabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.addon.RemoteAddon; @@ -41,7 +41,7 @@ public class FabricAPIRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ FabricAPIRemoteVersion(String gameVersion, String selfVersion, String fullVersion, Instant datePublished, RemoteAddon.Version version, List urls) { - super(LibraryAnalyzer.LibraryType.FABRIC_API.getPatchId(), gameVersion, selfVersion, datePublished, urls); + super(GameComponentType.FABRIC_API.getPatchId(), gameVersion, selfVersion, datePublished, urls); this.fullVersion = fullVersion; this.version = version; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java index 03e98895d3c..fc0810a6fdc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java @@ -184,6 +184,11 @@ public GameInstancePatch withId(@Nullable String id) { return builder.toPatch(); } + /// Returns a patch copy with the given id. + public GameInstancePatch withId(@Nullable GameComponentType type) { + return withId(type != null ? type.getPatchId() : null); + } + /// Returns a patch copy with the given version. public GameInstancePatch withVersion(@Nullable String version) { if (Objects.equals(this.version, version)) { @@ -307,10 +312,14 @@ private static final class Builder { private @Nullable String assets; private @Nullable Integer complianceLevel; private @Nullable GameJavaVersion javaVersion; - private @Nullable @Unmodifiable List libraries; - private @Nullable @Unmodifiable List compatibilityRules; - private @Nullable @Unmodifiable Map downloads; - private @Nullable @Unmodifiable Map logging; + private @Nullable + @Unmodifiable List libraries; + private @Nullable + @Unmodifiable List compatibilityRules; + private @Nullable + @Unmodifiable Map downloads; + private @Nullable + @Unmodifiable Map logging; private @Nullable ReleaseType type; private @Nullable Instant time; private @Nullable Instant releaseTime; From 04f6b753bad13b34bbe6420a762165e8fa6083d1 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:19:42 +0800 Subject: [PATCH 103/199] feat(RemoteVersion): replace LibraryAnalyzer with GameComponentType for improved consistency --- .../jackhuang/hmcl/download/RemoteVersion.java | 17 ++++++++++++----- .../cleanroom/CleanroomRemoteVersion.java | 2 +- .../download/fabric/FabricAPIRemoteVersion.java | 2 +- .../download/fabric/FabricRemoteVersion.java | 3 ++- .../hmcl/download/forge/ForgeRemoteVersion.java | 3 ++- .../hmcl/download/game/GameRemoteVersion.java | 3 ++- .../LegacyFabricAPIRemoteVersion.java | 3 ++- .../legacyfabric/LegacyFabricRemoteVersion.java | 3 ++- .../liteloader/LiteLoaderRemoteVersion.java | 3 ++- .../neoforge/NeoForgeRemoteVersion.java | 3 ++- .../optifine/OptiFineRemoteVersion.java | 3 ++- .../download/quilt/QuiltAPIRemoteVersion.java | 3 ++- .../hmcl/download/quilt/QuiltRemoteVersion.java | 3 ++- .../jackhuang/hmcl/util/SettingsMapTest.java | 12 ++++++------ 14 files changed, 40 insertions(+), 23 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java index 4bd8f79f057..5c38a5a4c5b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -35,6 +36,7 @@ */ public class RemoteVersion implements Comparable { + private final GameComponentType componentType; private final String libraryId; private final String gameVersion; private final String selfVersion; @@ -49,8 +51,8 @@ public class RemoteVersion implements Comparable { * @param selfVersion the version string of the remote version. * @param urls the installer or universal jar original URL. */ - public RemoteVersion(String libraryId, String gameVersion, String selfVersion, Instant releaseDate, List urls) { - this(libraryId, gameVersion, selfVersion, releaseDate, Type.UNCATEGORIZED, urls); + public RemoteVersion(GameComponentType componentType, String gameVersion, String selfVersion, Instant releaseDate, List urls) { + this(componentType, gameVersion, selfVersion, releaseDate, Type.UNCATEGORIZED, urls); } /** @@ -60,8 +62,9 @@ public RemoteVersion(String libraryId, String gameVersion, String selfVersion, I * @param selfVersion the version string of the remote version. * @param urls the installer or universal jar URL. */ - public RemoteVersion(String libraryId, String gameVersion, String selfVersion, Instant releaseDate, Type type, List urls) { - this.libraryId = Objects.requireNonNull(libraryId); + public RemoteVersion(GameComponentType componentType, String gameVersion, String selfVersion, Instant releaseDate, Type type, List urls) { + this.componentType = Objects.requireNonNull(componentType); + this.libraryId = componentType.getPatchId(); this.gameVersion = Objects.requireNonNull(gameVersion); this.selfVersion = Objects.requireNonNull(selfVersion); this.releaseDate = releaseDate; @@ -69,8 +72,12 @@ public RemoteVersion(String libraryId, String gameVersion, String selfVersion, I this.type = Objects.requireNonNull(type); } + public GameComponentType getComponentType() { + return componentType; + } + public String getLibraryId() { - return libraryId; + return getComponentType().getPatchId(); } public String getGameVersion() { 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 3611eb70fc1..e7a3d073922 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomRemoteVersion.java @@ -29,7 +29,7 @@ public class CleanroomRemoteVersion extends RemoteVersion { public CleanroomRemoteVersion(String gameVersion, String selfVersion, Instant releaseDate, List url) { - super(GameComponentType.CLEANROOM.getPatchId(), gameVersion, selfVersion, releaseDate, url); + super(GameComponentType.CLEANROOM, gameVersion, selfVersion, releaseDate, url); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIRemoteVersion.java index fad87dfbba4..03b287370cb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricAPIRemoteVersion.java @@ -41,7 +41,7 @@ public class FabricAPIRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ FabricAPIRemoteVersion(String gameVersion, String selfVersion, String fullVersion, Instant datePublished, RemoteAddon.Version version, List urls) { - super(GameComponentType.FABRIC_API.getPatchId(), gameVersion, selfVersion, datePublished, urls); + super(GameComponentType.FABRIC_API, gameVersion, selfVersion, datePublished, urls); this.fullVersion = fullVersion; this.version = version; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricRemoteVersion.java index 857c593eeaf..e8d0231fee6 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -35,7 +36,7 @@ public class FabricRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ FabricRemoteVersion(String gameVersion, String selfVersion, List urls) { - super(LibraryAnalyzer.LibraryType.FABRIC.getPatchId(), gameVersion, selfVersion, null, urls); + super(GameComponentType.FABRIC, gameVersion, selfVersion, null, urls); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeRemoteVersion.java index 34278a822c0..320eb08bfb0 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -36,7 +37,7 @@ public class ForgeRemoteVersion extends RemoteVersion { * @param url the installer or universal jar original URL. */ public ForgeRemoteVersion(String gameVersion, String selfVersion, Instant releaseDate, List url) { - super(LibraryAnalyzer.LibraryType.FORGE.getPatchId(), gameVersion, selfVersion, releaseDate, url); + super(GameComponentType.FORGE, gameVersion, selfVersion, releaseDate, url); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameRemoteVersion.java index 386ac9367ad..725030fb4ea 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.game.ReleaseType; @@ -40,7 +41,7 @@ public final class GameRemoteVersion extends RemoteVersion { private final ReleaseType type; public GameRemoteVersion(String gameVersion, String selfVersion, List url, ReleaseType type, Instant releaseDate) { - super(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), gameVersion, selfVersion, releaseDate, getReleaseType(type), url); + super(GameComponentType.GAME, gameVersion, selfVersion, releaseDate, getReleaseType(type), url); this.type = type; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java index cb7f700291d..8f80cb69566 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.addon.RemoteAddon; @@ -41,7 +42,7 @@ public class LegacyFabricAPIRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ LegacyFabricAPIRemoteVersion(String gameVersion, String selfVersion, String fullVersion, Instant datePublished, RemoteAddon.Version version, List urls) { - super(LibraryAnalyzer.LibraryType.LEGACY_FABRIC_API.getPatchId(), gameVersion, selfVersion, datePublished, urls); + super(GameComponentType.LEGACY_FABRIC_API, gameVersion, selfVersion, datePublished, urls); this.fullVersion = fullVersion; this.version = version; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java index f8a1e88c881..4a6827dd3db 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -35,7 +36,7 @@ public class LegacyFabricRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ LegacyFabricRemoteVersion(String gameVersion, String selfVersion, List urls) { - super(LibraryAnalyzer.LibraryType.LEGACY_FABRIC.getPatchId(), gameVersion, selfVersion, null, urls); + super(GameComponentType.LEGACY_FABRIC, gameVersion, selfVersion, null, urls); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderRemoteVersion.java index e2da5e16f84..c81f66609eb 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.game.Library; @@ -40,7 +41,7 @@ public class LiteLoaderRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ LiteLoaderRemoteVersion(String gameVersion, String selfVersion, Type type, List urls, String tweakClass, Collection libraries) { - super(LibraryAnalyzer.LibraryType.LITELOADER.getPatchId(), gameVersion, selfVersion, null, type, urls); + super(GameComponentType.LITELOADER, gameVersion, selfVersion, null, type, urls); this.tweakClass = tweakClass; this.libraries = libraries; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java index 9582c6c7f7d..7a992bb34ae 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -28,7 +29,7 @@ public class NeoForgeRemoteVersion extends RemoteVersion { public NeoForgeRemoteVersion(String gameVersion, String selfVersion, List urls) { - super(LibraryAnalyzer.LibraryType.NEO_FORGE.getPatchId(), gameVersion, selfVersion, null, getType(selfVersion), urls); + super(GameComponentType.NEO_FORGE, gameVersion, selfVersion, null, getType(selfVersion), urls); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java index abf36261e7a..98278d20dc0 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -29,7 +30,7 @@ public class OptiFineRemoteVersion extends RemoteVersion { public OptiFineRemoteVersion(String gameVersion, String selfVersion, List urls, boolean snapshot) { - super(LibraryAnalyzer.LibraryType.OPTIFINE.getPatchId(), gameVersion, selfVersion, null, snapshot ? Type.SNAPSHOT : Type.RELEASE, urls); + super(GameComponentType.OPTIFINE, gameVersion, selfVersion, null, snapshot ? Type.SNAPSHOT : Type.RELEASE, urls); } @Override 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 ae8076499b6..840107df6c4 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.addon.RemoteAddon; @@ -41,7 +42,7 @@ public class QuiltAPIRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ QuiltAPIRemoteVersion(String gameVersion, String selfVersion, String fullVersion, Instant datePublished, RemoteAddon.Version version, List urls) { - super(LibraryAnalyzer.LibraryType.QUILT_API.getPatchId(), gameVersion, selfVersion, datePublished, urls); + super(GameComponentType.QUILT_API, gameVersion, selfVersion, datePublished, urls); this.fullVersion = fullVersion; this.version = version; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltRemoteVersion.java index bd86cd24ea3..1ad8faf0022 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -35,7 +36,7 @@ public class QuiltRemoteVersion extends RemoteVersion { * @param urls the installer or universal jar original URL. */ QuiltRemoteVersion(String gameVersion, String selfVersion, List urls) { - super(LibraryAnalyzer.LibraryType.QUILT.getPatchId(), gameVersion, selfVersion, null, urls); + super(GameComponentType.QUILT, gameVersion, selfVersion, null, urls); } @Override diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java index 3a4fda31d96..c04c1adebe3 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/SettingsMapTest.java @@ -17,8 +17,8 @@ */ package org.jackhuang.hmcl.util; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jetbrains.annotations.NotNullByDefault; import org.junit.jupiter.api.Test; @@ -35,7 +35,7 @@ public final class SettingsMapTest { @Test public void minecraftSelectionIsNotModdedInstallation() { SettingsMap settings = new SettingsMap(); - settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), remoteVersion("game")); + settings.put(GameComponentType.GAME.getPatchId(), remoteVersion(GameComponentType.GAME)); assertFalse(settings.isInstallingModdedVersion()); } @@ -44,14 +44,14 @@ public void minecraftSelectionIsNotModdedInstallation() { @Test public void modLoaderSelectionIsModdedInstallation() { SettingsMap settings = new SettingsMap(); - settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), remoteVersion("game")); - settings.put(LibraryAnalyzer.LibraryType.FABRIC.getPatchId(), remoteVersion("fabric")); + settings.put(GameComponentType.GAME.getPatchId(), remoteVersion(GameComponentType.GAME)); + settings.put(GameComponentType.FABRIC.getPatchId(), remoteVersion(GameComponentType.FABRIC)); assertTrue(settings.isInstallingModdedVersion()); } /// Creates a minimal remote version for installer state tests. - private static RemoteVersion remoteVersion(String libraryId) { - return new RemoteVersion(libraryId, "1.21.11", "test", Instant.EPOCH, List.of()); + private static RemoteVersion remoteVersion(GameComponentType componentType) { + return new RemoteVersion(componentType, "1.21.11", "test", Instant.EPOCH, List.of()); } } From b48a7640b6ba4088e76c01cbec296e5511e070bb Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:22:33 +0800 Subject: [PATCH 104/199] feat(InstallerItem, InstallersPage): replace LibraryAnalyzer with GameComponentType for improved type handling --- .../org/jackhuang/hmcl/ui/InstallerItem.java | 2 +- .../hmcl/ui/download/InstallersPage.java | 39 ++++++++----------- 2 files changed, 18 insertions(+), 23 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java index 164acba67bb..84fb5d465a3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java @@ -94,7 +94,7 @@ public InstallerItem(GameComponentType type, Style style) { public GameComponentType getComponentType() { return type; } - + public String getLibraryId() { return id; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java index 618f712930a..b0e4498377b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java @@ -18,8 +18,8 @@ package org.jackhuang.hmcl.ui.download; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.ui.Controllers; @@ -116,29 +116,24 @@ private void setTxtNameWithLoaders() { StringBuilder nameBuilder = new StringBuilder(getTitle()); for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId().replace(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), ""); - if (!controller.getSettings().containsKey(libraryId)) { + if (library.getComponentType() == GameComponentType.GAME + || !controller.getSettings().containsKey(library.getComponentType().getPatchId())) continue; - } - LibraryAnalyzer.LibraryType libraryType = LibraryAnalyzer.LibraryType.fromPatchId(libraryId); - - if (libraryType != null) { - String loaderName = switch (libraryType) { - case FORGE -> "Forge"; - case NEO_FORGE -> "NeoForge"; - case CLEANROOM -> "Cleanroom"; - case LEGACY_FABRIC -> "LegacyFabric"; - case FABRIC -> "Fabric"; - case LITELOADER -> "LiteLoader"; - case QUILT -> "Quilt"; - case OPTIFINE -> "OptiFine"; - default -> null; - }; - - if (loaderName != null) - nameBuilder.append('-').append(loaderName); - } + String loaderName = switch (library.getComponentType()) { + case FORGE -> "Forge"; + case NEO_FORGE -> "NeoForge"; + case CLEANROOM -> "Cleanroom"; + case LEGACY_FABRIC -> "LegacyFabric"; + case FABRIC -> "Fabric"; + case LITELOADER -> "LiteLoader"; + case QUILT -> "Quilt"; + case OPTIFINE -> "OptiFine"; + default -> null; + }; + + if (loaderName != null) + nameBuilder.append('-').append(loaderName); } txtName.setText(nameBuilder.toString()); From 9a1478e41746da9f79047adc7da99c15f9da6d20 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:28:14 +0800 Subject: [PATCH 105/199] feat(LiteLoaderInstallTask, MultiMCComponents, MultiMCInstancePatch, MultiMCModpackExportTask, NeoForgeInstallTask, NeoForgeOldInstallTask, OptiFineInstallTask): replace LibraryAnalyzer with GameComponentType for improved consistency and type handling --- .../liteloader/LiteLoaderInstallTask.java | 3 +-- .../neoforge/NeoForgeInstallTask.java | 12 ++++----- .../neoforge/NeoForgeOldInstallTask.java | 3 +-- .../optifine/OptiFineInstallTask.java | 2 +- .../modpack/multimc/MultiMCComponents.java | 26 +++++++++---------- .../modpack/multimc/MultiMCInstancePatch.java | 3 +-- .../multimc/MultiMCModpackExportTask.java | 17 ++++++------ .../multimc/MultiMCModpackInstallTask.java | 3 +-- 8 files changed, 33 insertions(+), 36 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java index 330a7cb4787..59c1d53cd1d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderInstallTask.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.liteloader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; @@ -65,7 +64,7 @@ public void execute() { new LibrariesDownloadInfo(new LibraryDownloadInfo(null, remote.getUrls().get(0))) ); - setResult(new GameInstancePatch(LibraryAnalyzer.LibraryType.LITELOADER.getPatchId(), + setResult(new GameInstancePatch(GameComponentType.LITELOADER.getPatchId(), remote.getSelfVersion(), 60000, new Arguments().addGameArguments("--tweakClass", "com.mumfrey.liteloader.launch.LiteLoaderTweaker"), diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java index 3467a195d37..eb3ac1a6c2a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java @@ -18,9 +18,9 @@ package org.jackhuang.hmcl.download.neoforge; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.VersionMismatchException; import org.jackhuang.hmcl.download.forge.*; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -106,20 +106,20 @@ public static Task install(DefaultDependencyManager dependenc try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); - if (LibraryAnalyzer.LibraryType.FORGE.getPatchId().equals(installProfile.get("profile")) && (Files.exists(fs.getPath("META-INF/NEOFORGE.RSA")) || installProfileText.contains("neoforge"))) { + if (GameComponentType.FORGE.getPatchId().equals(installProfile.get("profile")) && (Files.exists(fs.getPath("META-INF/NEOFORGE.RSA")) || installProfileText.contains("neoforge"))) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.get().equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); return new ForgeNewInstallTask(dependencyManager, version, modifyNeoForgeOldVersion(gameVersion.get(), profile.getVersion()), installer).thenApplyAsync(neoForgeVersion -> { - if (!neoForgeVersion.id().equals(LibraryAnalyzer.LibraryType.FORGE.getPatchId()) || neoForgeVersion.version() == null) { + if (!neoForgeVersion.id().equals(GameComponentType.FORGE.getPatchId()) || neoForgeVersion.version() == null) { throw new IOException("Invalid neoforge version."); } - return neoForgeVersion.withId(LibraryAnalyzer.LibraryType.NEO_FORGE.getPatchId()) + return neoForgeVersion.withId(GameComponentType.NEO_FORGE.getPatchId()) .withVersion( - removePrefix(neoForgeVersion.version().replace(LibraryAnalyzer.LibraryType.FORGE.getPatchId(), ""), "-") + removePrefix(neoForgeVersion.version().replace(GameComponentType.FORGE.getPatchId(), ""), "-") ); }); - } else if (LibraryAnalyzer.LibraryType.NEO_FORGE.getPatchId().equals(installProfile.get("profile")) || "NeoForge".equals(installProfile.get("profile"))) { + } else if (GameComponentType.NEO_FORGE.getPatchId().equals(installProfile.get("profile")) || "NeoForge".equals(installProfile.get("profile"))) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.get().equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java index 0d79290592b..447f3ec02e7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java @@ -19,7 +19,6 @@ import org.jackhuang.hmcl.download.ArtifactMalformedException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile; import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile.Processor; import org.jackhuang.hmcl.download.game.GameLibrariesTask; @@ -406,7 +405,7 @@ public void execute() throws Exception { setResult(GameInstancePatch.fromManifest( neoForgeVersion, - LibraryAnalyzer.LibraryType.NEO_FORGE.getPatchId(), + GameComponentType.NEO_FORGE.getPatchId(), selfVersion, GameInstancePatch.PRIORITY_LOADER)); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java index b3e3e2fdcd1..f963246a416 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 @@ -208,7 +208,7 @@ public void execute() throws Exception { } setResult(new GameInstancePatch( - LibraryAnalyzer.LibraryType.OPTIFINE.getPatchId(), + GameComponentType.OPTIFINE.getPatchId(), remote.getSelfVersion(), 10000, new Arguments().addGameArguments("--tweakClass", "optifine.OptiFineTweaker"), diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCComponents.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCComponents.java index ba1e2604153..b4b8aa2a5bd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCComponents.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCComponents.java @@ -17,7 +17,7 @@ */ package org.jackhuang.hmcl.modpack.multimc; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.util.io.NetworkUtils; import java.net.URI; @@ -66,21 +66,21 @@ public static String getInstallerProfile() { return builder.toString(); } - private static final Map ID_TYPE = new HashMap<>(); + private static final Map ID_TYPE = new HashMap<>(); static { - ID_TYPE.put("net.minecraft", LibraryAnalyzer.LibraryType.MINECRAFT); - ID_TYPE.put("net.minecraftforge", LibraryAnalyzer.LibraryType.FORGE); - ID_TYPE.put("net.neoforged", LibraryAnalyzer.LibraryType.NEO_FORGE); - ID_TYPE.put("com.mumfrey.liteloader", LibraryAnalyzer.LibraryType.LITELOADER); - ID_TYPE.put("net.fabricmc.fabric-loader", LibraryAnalyzer.LibraryType.FABRIC); - ID_TYPE.put("org.quiltmc.quilt-loader", LibraryAnalyzer.LibraryType.QUILT); + ID_TYPE.put("net.minecraft", GameComponentType.GAME); + ID_TYPE.put("net.minecraftforge", GameComponentType.FORGE); + ID_TYPE.put("net.neoforged", GameComponentType.NEO_FORGE); + ID_TYPE.put("com.mumfrey.liteloader", GameComponentType.LITELOADER); + ID_TYPE.put("net.fabricmc.fabric-loader", GameComponentType.FABRIC); + ID_TYPE.put("org.quiltmc.quilt-loader", GameComponentType.QUILT); } - private static final Map TYPE_ID = + private static final Map TYPE_ID = ID_TYPE.entrySet().stream().collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey)); - private static final Collection> PAIRS = Collections.unmodifiableCollection(ID_TYPE.entrySet()); + private static final Collection> PAIRS = Collections.unmodifiableCollection(ID_TYPE.entrySet()); static { if (TYPE_ID.isEmpty()) { @@ -88,15 +88,15 @@ public static String getInstallerProfile() { } } - public static String getComponent(LibraryAnalyzer.LibraryType type) { + public static String getComponent(GameComponentType type) { return TYPE_ID.get(type); } - public static LibraryAnalyzer.LibraryType getComponent(String type) { + public static GameComponentType getComponent(String type) { return ID_TYPE.get(type); } - public static Collection> getPairs() { + public static Collection> getPairs() { return PAIRS; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java index cd826f2c1fb..77db9f8c286 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java @@ -19,7 +19,6 @@ import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.Lang; @@ -413,7 +412,7 @@ public static ResolvedInstance resolveArtifact(List patche String gameVersion = null; for (MultiMCInstancePatch patch : patches) { - if (MultiMCComponents.getComponent(patch.getID()) == LibraryAnalyzer.LibraryType.MINECRAFT) { + if (MultiMCComponents.getComponent(patch.getID()) == GameComponentType.GAME) { gameVersion = patch.getVersion(); break; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java index 91692d278f3..25a0d9a2962 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java @@ -17,8 +17,9 @@ */ package org.jackhuang.hmcl.modpack.multimc; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackExportInfo; @@ -36,7 +37,6 @@ import java.util.List; import java.util.Map; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// Exports one registered game instance as a MultiMC modpack archive. @@ -93,15 +93,16 @@ public void execute() throws Exception { throw new IOException("Cannot parse the version of " + instanceId); } String gameVersion = version.toString(); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); List components = new ArrayList<>(); - components.add(new MultiMCManifest.MultiMCManifestComponent(true, false, MultiMCComponents.getComponent(MINECRAFT), gameVersion)); + components.add(new MultiMCManifest.MultiMCManifestComponent(true, false, MultiMCComponents.getComponent(GameComponentType.GAME), gameVersion)); - for (Map.Entry pair : MultiMCComponents.getPairs()) { + for (Map.Entry pair : MultiMCComponents.getPairs()) { if (pair.getValue().isModLoader()) { - analyzer.getVersion(pair.getValue()).ifPresent( - v -> components.add(new MultiMCManifest.MultiMCManifestComponent(false, false, pair.getKey(), v)) - ); + String componentVersion = analyzer.getVersion(pair.getValue()); + if (componentVersion != null) { + components.add(new MultiMCManifest.MultiMCManifestComponent(false, false, pair.getKey(), componentVersion)); + } } } 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 aff974ea172..5a21a656a1a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java @@ -19,7 +19,6 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.game.GameAssetDownloadTask; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameLibrariesTask; @@ -145,7 +144,7 @@ public void preExecute() throws Exception { String mcVersion = null; for (MultiMCManifest.MultiMCManifestComponent component : components) { - if (MultiMCComponents.getComponent(component.getUid()) == LibraryAnalyzer.LibraryType.MINECRAFT) { + if (MultiMCComponents.getComponent(component.getUid()) == GameComponentType.GAME) { mcVersion = component.getVersion(); break; } From 754a5b50f7344efaf97108d60b848d745d24219d Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:30:15 +0800 Subject: [PATCH 106/199] feat(DefaultLauncher, DownloadPage, MultiMCInstancePatch): replace LibraryAnalyzer with GameComponentAnalyzer for improved type handling --- .../hmcl/ui/download/DownloadPage.java | 7 +++--- .../hmcl/launch/DefaultLauncher.java | 23 +++++++++---------- .../modpack/multimc/MultiMCInstancePatch.java | 1 + 3 files changed, 16 insertions(+), 15 deletions(-) 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 43f7e60b675..35993618f28 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -25,6 +25,7 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.download.*; import org.jackhuang.hmcl.download.game.GameRemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; @@ -304,7 +305,7 @@ public VanillaInstallWizardProvider(HMCLGameRepository repository, GameRemoteVer public void start(SettingsMap settings) { settings.put(ModpackPage.GAME_DIRECTORY, repository.getGameDirectory()); settings.put(ModpackPage.REPOSITORY, repository); - settings.put(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId(), gameVersion); + settings.put(GameComponentType.GAME.getPatchId(), gameVersion); } private Task finishVersionDownloadingAsync(SettingsMap settings) { @@ -312,10 +313,10 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID); builder.name(instanceId); - builder.gameVersion(((RemoteVersion) settings.get(LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId())).getGameVersion()); + builder.gameVersion(((RemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion()); settings.asStringMap().forEach((key, value) -> { - if (!LibraryAnalyzer.LibraryType.MINECRAFT.getPatchId().equals(key) + if (!GameComponentType.GAME.getPatchId().equals(key) && value instanceof RemoteVersion remoteVersion) builder.version(remoteVersion); }); 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 511f0754a5f..4884db15c52 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -19,7 +19,6 @@ import org.glavo.uuid.UUIDs; import org.jackhuang.hmcl.auth.AuthInfo; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.ServerAddress; @@ -50,13 +49,13 @@ */ public class DefaultLauncher extends Launcher { - private final LibraryAnalyzer analyzer; + private final GameComponentAnalyzer analyzer; public DefaultLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { super(instance, manifest, authInfo, options, listener, daemon); GameVersionNumber version = instance.getVersion(); - this.analyzer = LibraryAnalyzer.analyze(manifest, + this.analyzer = GameComponentAnalyzer.analyze(manifest, version == GameVersionNumber.unknown() ? null : version.toString()); } @@ -277,7 +276,7 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { Set classpath = LaunchClasspathResolver.resolve(instance.getRepository(), manifest); - if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) { + if (analyzer.has(GameComponentType.CLEANROOM)) { classpath.removeIf(c -> c.contains("2.9.4-nightly-20150209")); } @@ -686,28 +685,28 @@ else if (driver instanceof Renderer.Vulkan vulkanDriver) { } } - if (analyzer.has(LibraryAnalyzer.LibraryType.FORGE)) { + if (analyzer.has(GameComponentType.FORGE)) { env.put("INST_FORGE", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.CLEANROOM)) { + if (analyzer.has(GameComponentType.CLEANROOM)) { env.put("INST_CLEANROOM", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) { + if (analyzer.has(GameComponentType.NEO_FORGE)) { env.put("INST_NEOFORGE", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.LITELOADER)) { + if (analyzer.has(GameComponentType.LITELOADER)) { env.put("INST_LITELOADER", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.FABRIC)) { + if (analyzer.has(GameComponentType.FABRIC)) { env.put("INST_FABRIC", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.OPTIFINE)) { + if (analyzer.has(GameComponentType.OPTIFINE)) { env.put("INST_OPTIFINE", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.QUILT)) { + if (analyzer.has(GameComponentType.QUILT)) { env.put("INST_QUILT", "1"); } - if (analyzer.has(LibraryAnalyzer.LibraryType.LEGACY_FABRIC)) { + if (analyzer.has(GameComponentType.LEGACY_FABRIC)) { env.put("INST_LEGACYFABRIC", "1"); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java index 77db9f8c286..8278a2331ef 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCInstancePatch.java @@ -19,6 +19,7 @@ import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; + import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.Lang; From 598c544a0c1b5535dff6835c18a436487f49d668 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:34:21 +0800 Subject: [PATCH 107/199] feat(ServerModpackExportTask): replace LibraryAnalyzer with GameComponentAnalyzer for improved mod component handling --- .../server/ServerModpackExportTask.java | 28 ++++++++----------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java index 3e7e610cf76..b33e8c8ea22 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java @@ -17,8 +17,9 @@ */ package org.jackhuang.hmcl.modpack.server; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; @@ -38,7 +39,6 @@ import java.util.ArrayList; import java.util.List; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// Exports one registered game instance as an HMCL server modpack archive. @@ -103,21 +103,17 @@ public void execute() throws Exception { throw new IOException("Cannot parse the version of " + instanceId); } String gameVersion = version.toString(); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); List addons = new ArrayList<>(); - addons.add(new ServerModpackManifest.Addon(MINECRAFT.getPatchId(), gameVersion)); - analyzer.getVersion(FORGE).ifPresent(forgeVersion -> - addons.add(new ServerModpackManifest.Addon(FORGE.getPatchId(), forgeVersion))); - analyzer.getVersion(NEO_FORGE).ifPresent(neoForgeVersion -> - addons.add(new ServerModpackManifest.Addon(NEO_FORGE.getPatchId(), neoForgeVersion))); - analyzer.getVersion(LITELOADER).ifPresent(liteLoaderVersion -> - addons.add(new ServerModpackManifest.Addon(LITELOADER.getPatchId(), liteLoaderVersion))); - analyzer.getVersion(OPTIFINE).ifPresent(optifineVersion -> - addons.add(new ServerModpackManifest.Addon(OPTIFINE.getPatchId(), optifineVersion))); - analyzer.getVersion(FABRIC).ifPresent(fabricVersion -> - addons.add(new ServerModpackManifest.Addon(FABRIC.getPatchId(), fabricVersion))); - analyzer.getVersion(QUILT).ifPresent(quiltVersion -> - addons.add(new ServerModpackManifest.Addon(QUILT.getPatchId(), quiltVersion))); + addons.add(new ServerModpackManifest.Addon(GameComponentType.GAME.getPatchId(), gameVersion)); + + for (GameComponentAnalyzer.Mark mark : analyzer) { + if ((mark.componentType().isModLoader() || mark.componentType() == GameComponentType.OPTIFINE) + && mark.version() != null) { + addons.add(new ServerModpackManifest.Addon(mark.componentType().getPatchId(), mark.version())); + } + } + ServerModpackManifest manifest = new ServerModpackManifest(exportInfo.getName(), exportInfo.getAuthor(), exportInfo.getVersion(), exportInfo.getDescription(), StringUtils.removeSuffix(exportInfo.getFileApi(), "/"), files, addons); zip.putTextFile(JsonUtils.GSON.toJson(manifest), "server-manifest.json"); } From 05e5db0dc143607dfdc1636776f024fcb1086932 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:44:19 +0800 Subject: [PATCH 108/199] feat(GameItem, InstallerListPage, LaunchManifestNormalizer, McbbsModpackManifest, ModrinthModpackExportTask): replace LibraryAnalyzer with GameComponentAnalyzer for improved component handling --- .../jackhuang/hmcl/ui/instances/GameItem.java | 23 ++++++------- .../hmcl/ui/instances/InstallerListPage.java | 32 +++++++------------ .../hmcl/game/LaunchManifestNormalizer.java | 7 ++-- .../modpack/mcbbs/McbbsModpackManifest.java | 5 ++- .../modrinth/ModrinthModpackExportTask.java | 14 ++++---- 5 files changed, 34 insertions(+), 47 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java index 17eba127449..f65c476f4b1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java @@ -19,10 +19,7 @@ import javafx.beans.property.*; import javafx.scene.image.Image; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.setting.GameDirectory; import org.jackhuang.hmcl.task.Schedulers; @@ -36,7 +33,6 @@ import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; import static org.jackhuang.hmcl.util.Lang.threadPool; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -108,15 +104,14 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { } StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown"))); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(gameInstance.getResolvedManifest(), result.gameVersion); - for (LibraryAnalyzer.LibraryMark mark : analyzer) { - String libraryId = mark.getLibraryId(); - String libraryVersion = mark.getLibraryVersion(); - if (libraryId.equals(MINECRAFT.getPatchId())) continue; - if (I18n.hasKey("install.installer." + libraryId)) { - libraries.append(", ").append(i18n("install.installer." + libraryId)); - if (libraryVersion != null) - libraries.append(": ").append(libraryVersion.replaceAll("(?i)" + libraryId, "")); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(gameInstance.getResolvedManifest(), result.gameVersion); + for (GameComponentAnalyzer.Mark mark : analyzer) { + if (mark.componentType() == GameComponentType.GAME) continue; + + if (I18n.hasKey("install.installer." + mark.componentType().getPatchId())) { + libraries.append(", ").append(i18n("install.installer." + mark.componentType().getPatchId())); + if (mark.version() != null) + libraries.append(": ").append(mark.version().replaceAll("(?i)" + mark.componentType().getPatchId(), "")); } } 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 beecb38f620..0e8d8c4f1bd 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 @@ -21,7 +21,7 @@ import javafx.scene.Node; import javafx.scene.control.Skin; import javafx.stage.FileChooser; -import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.task.Schedulers; @@ -78,7 +78,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { HMCLGameRepository repository = gameInstance.getRepository(); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(gameInstance.getResolvedManifest(), gameInstance.getVersion().toString()); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(gameInstance.getResolvedManifest(), gameInstance.getVersion().toString()); itemsProperty().clear(); @@ -86,19 +86,18 @@ public void loadInstance(HMCLGameInstance.Optional instance) { // Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine for (InstallerItem item : group.getLibraries()) { - String libraryId = item.getLibraryId(); // Skip fabric-api and quilt-api and legacyfabric-api - if (libraryId.endsWith("-api")) { + if (item.getComponentType().getPatchId().endsWith("-api")) { continue; } - String libraryVersion = analyzer.getVersion(libraryId).orElse(null); + String libraryVersion = analyzer.getVersion(item.getComponentType()); if (libraryVersion != null) { item.versionProperty().set(new InstallerItem.InstalledState( libraryVersion, - analyzer.getLibraryStatus(libraryId) != LibraryAnalyzer.LibraryMark.LibraryStatus.CLEAR, + !analyzer.isClear(item.getComponentType()), false )); } else { @@ -106,10 +105,10 @@ public void loadInstance(HMCLGameInstance.Optional instance) { } item.setOnInstall(() -> { - Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, libraryId, libraryVersion)); + Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, item.getComponentType().getPatchId(), libraryVersion)); }); - item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), libraryId) + item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), item.getComponentType().getPatchId()) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) @@ -119,22 +118,15 @@ public void loadInstance(HMCLGameInstance.Optional instance) { } // other third-party libraries which are unable to manage. - for (LibraryAnalyzer.LibraryMark mark : analyzer) { - String libraryId = mark.getLibraryId(); - String libraryVersion = mark.getLibraryVersion(); - if ("mcbbs".equals(libraryId)) - continue; - + for (GameComponentAnalyzer.Mark mark : analyzer) { // we have done this library above. - if (LibraryAnalyzer.LibraryType.fromPatchId(libraryId) != null) - continue; - InstallerItem installerItem = new InstallerItem(libraryId, InstallerItem.Style.LIST_ITEM); - installerItem.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false)); - installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), libraryId) + InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM); + installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false)); + installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), mark.componentType().getPatchId()) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) + .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) .start()); itemsProperty().add(installerItem); 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 77e8ca4d533..b965d1e2298 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -27,6 +27,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Optional; /// Normalizes a structurally resolved manifest into the stable view consumed by launch-time code. /// @@ -179,12 +180,12 @@ private static GameInstanceManifest normalizeModLauncher(GameInstanceManifest ma /// @param manifest the resolved manifest /// @return the repaired manifest private static GameInstanceManifest normalizeBootstrapLauncher(GameInstanceManifest manifest) { - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); - if (!analyzer.has(LibraryAnalyzer.LibraryType.FORGE) && !analyzer.has(LibraryAnalyzer.LibraryType.NEO_FORGE)) { + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + if (!analyzer.has(GameComponentType.FORGE) && !analyzer.has(GameComponentType.NEO_FORGE)) { return manifest; } - if (analyzer.getVersion(LibraryAnalyzer.LibraryType.BOOTSTRAP_LAUNCHER) + if (Optional.ofNullable(analyzer.getVersion(GameComponentType.BOOTSTRAP_LAUNCHER)) .filter(version -> VersionNumber.compare(version, "0.1.17") >= 0) .isEmpty()) { return manifest; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java index 69e63a1ee59..24d1b5b968e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java @@ -20,6 +20,7 @@ import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.game.Library; @@ -38,8 +39,6 @@ import java.util.Objects; import java.util.Optional; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; - public class McbbsModpackManifest implements ModpackManifest, Validation { public static final String MANIFEST_TYPE = "minecraftModpack"; @@ -421,7 +420,7 @@ public String getAuthlibInjectorServer() { } public Modpack toModpack(Charset encoding) throws IOException { - String gameVersion = addons.stream().filter(x -> MINECRAFT.getPatchId().equals(x.id)).findAny() + String gameVersion = addons.stream().filter(x -> GameComponentType.GAME.getPatchId().equals(x.id)).findAny() .orElseThrow(() -> new IOException("Cannot find game version")).getVersion(); return new Modpack(name, author, version, gameVersion, description, encoding, this) { @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java index 1bb920e15a3..835ffccc323 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java @@ -26,8 +26,9 @@ import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackExportInfo; @@ -41,7 +42,6 @@ import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// Exports one registered game instance as a Modrinth modpack archive. @@ -198,18 +198,18 @@ public void execute() throws Exception { throw new IOException("Cannot parse the version of " + instanceId); } String gameVersion = version.toString(); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); Map dependencies = new HashMap<>(); dependencies.put("minecraft", gameVersion); - analyzer.getVersion(FORGE).ifPresent(forgeVersion -> + Optional.ofNullable(analyzer.getVersion(GameComponentType.FORGE)).ifPresent(forgeVersion -> dependencies.put("forge", forgeVersion)); - analyzer.getVersion(NEO_FORGE).ifPresent(neoForgeVersion -> + Optional.ofNullable(analyzer.getVersion(GameComponentType.NEO_FORGE)).ifPresent(neoForgeVersion -> dependencies.put("neoforge", neoForgeVersion)); - analyzer.getVersion(FABRIC).ifPresent(fabricVersion -> + Optional.ofNullable(analyzer.getVersion(GameComponentType.FABRIC)).ifPresent(fabricVersion -> dependencies.put("fabric-loader", fabricVersion)); - analyzer.getVersion(QUILT).ifPresent(quiltVersion -> + Optional.ofNullable(analyzer.getVersion(GameComponentType.QUILT)).ifPresent(quiltVersion -> dependencies.put("quilt-loader", quiltVersion)); ModrinthManifest manifest = new ModrinthManifest( From fb74bfd7a7b7a3177620b82b6cd04e7f0ee796a9 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:46:28 +0800 Subject: [PATCH 109/199] feat(FabricInstallTask, ForgeNewInstallTask, ForgeOldInstallTask, LaunchManifestPreparation, LegacyFabricInstallTask, QuiltInstallTask): replace LibraryAnalyzer with GameComponentType for improved patch handling --- .../hmcl/download/LaunchManifestPreparation.java | 11 ++++------- .../hmcl/download/fabric/FabricInstallTask.java | 9 ++------- .../hmcl/download/forge/ForgeNewInstallTask.java | 3 +-- .../hmcl/download/forge/ForgeOldInstallTask.java | 8 ++------ .../legacyfabric/LegacyFabricInstallTask.java | 9 ++------- .../hmcl/download/quilt/QuiltInstallTask.java | 9 ++------- 6 files changed, 13 insertions(+), 36 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java index 49ee8221eb4..d8078c85d7e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java @@ -27,12 +27,9 @@ import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; +import java.util.Optional; import java.util.stream.Stream; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.BOOTSTRAP_LAUNCHER; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.FORGE; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.NEO_FORGE; - /// Applies launch-manifest argument adjustments that depend on the installed filesystem. @NotNullByDefault public final class LaunchManifestPreparation { @@ -71,12 +68,12 @@ private static GameInstanceManifest prepareBootstrapLauncher( return manifest; } - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); - if (!analyzer.has(FORGE) && !analyzer.has(NEO_FORGE)) { + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + if (!analyzer.has(GameComponentType.FORGE) && !analyzer.has(GameComponentType.NEO_FORGE)) { return manifest; } - if (analyzer.getVersion(BOOTSTRAP_LAUNCHER) + if (Optional.ofNullable(analyzer.getVersion(GameComponentType.BOOTSTRAP_LAUNCHER)) .filter(version -> VersionNumber.compare(version, "0.1.17") < 0) .isEmpty()) { return manifest; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java index 72c8764cee6..88206744c19 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java @@ -20,14 +20,9 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.UnsupportedInstallationException; import org.jackhuang.hmcl.download.game.GameLibrariesTask; -import org.jackhuang.hmcl.game.Arguments; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonSerializable; @@ -127,7 +122,7 @@ private GameInstancePatch getPatch(FabricInfo fabricInfo, String gameVersion, St libraries.add(new Library(Artifact.fromDescriptor(fabricInfo.intermediary.maven), "https://maven.fabricmc.net/", null)); libraries.add(new Library(Artifact.fromDescriptor(fabricInfo.loader.maven), "https://maven.fabricmc.net/", null)); - return new GameInstancePatch(LibraryAnalyzer.LibraryType.FABRIC.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); + return new GameInstancePatch(GameComponentType.FABRIC.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); } @JsonSerializable diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java index cd880150df4..8ca59578786 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java @@ -19,7 +19,6 @@ import org.jackhuang.hmcl.download.ArtifactMalformedException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile.Processor; import org.jackhuang.hmcl.download.game.GameLibrariesTask; import org.jackhuang.hmcl.download.game.GameInstanceJsonDownloadTask; @@ -422,7 +421,7 @@ public void execute() throws Exception { setResult(GameInstancePatch.fromManifest( forgeVersion, - LibraryAnalyzer.LibraryType.FORGE.getPatchId(), + GameComponentType.FORGE.getPatchId(), selfVersion, GameInstancePatch.PRIORITY_LOADER)); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java index 06e18942c8f..703766b3720 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java @@ -19,11 +19,7 @@ import org.jackhuang.hmcl.download.ArtifactMalformedException; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; -import org.jackhuang.hmcl.game.GameRepository; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -86,7 +82,7 @@ public void execute() throws Exception { setResult(GameInstancePatch.fromManifest( installProfile.getVersionInfo(), - LibraryAnalyzer.LibraryType.FORGE.getPatchId(), + GameComponentType.FORGE.getPatchId(), selfVersion, GameInstancePatch.PRIORITY_LOADER)); dependencies.add(dependencyManager.checkLibraryCompletionAsync(installProfile.getVersionInfo(), true)); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java index ff0b6ef650a..b5a592e8b16 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricInstallTask.java @@ -20,13 +20,8 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.fabric.FabricInstallTask; -import org.jackhuang.hmcl.game.Arguments; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -111,7 +106,7 @@ private GameInstancePatch getPatch(FabricInstallTask.FabricInfo legacyFabricInfo libraries.add(new Library(Artifact.fromDescriptor(legacyFabricInfo.getIntermediary().getMaven()), getMavenRepositoryByGroup(legacyFabricInfo.getIntermediary().getMaven()), null)); libraries.add(new Library(Artifact.fromDescriptor(legacyFabricInfo.getLoader().getMaven()), getMavenRepositoryByGroup(legacyFabricInfo.getLoader().getMaven()), null)); - return new GameInstancePatch(LibraryAnalyzer.LibraryType.LEGACY_FABRIC.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); + return new GameInstancePatch(GameComponentType.LEGACY_FABRIC.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); } private static String getMavenRepositoryByGroup(String maven) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java index d2284294753..8a4fa50c965 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java @@ -20,13 +20,8 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.UnsupportedInstallationException; -import org.jackhuang.hmcl.game.Arguments; -import org.jackhuang.hmcl.game.Artifact; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; -import org.jackhuang.hmcl.game.Library; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonSerializable; @@ -126,7 +121,7 @@ private static GameInstancePatch getPatch(QuiltInfo quiltInfo, String loaderVers } libraries.add(new Library(Artifact.fromDescriptor(quiltInfo.loader.maven), getMavenRepositoryByGroup(quiltInfo.loader.maven), null)); - return new GameInstancePatch(LibraryAnalyzer.LibraryType.QUILT.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); + return new GameInstancePatch(GameComponentType.QUILT.getPatchId(), loaderVersion, GameInstancePatch.PRIORITY_LOADER, arguments, mainClass, libraries); } private static String getMavenRepositoryByGroup(String maven) { From 18bf4af6e77e9035a8031d47b33eb2f99b0eb2a0 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:51:12 +0800 Subject: [PATCH 110/199] feat(LaunchClasspathResolver, LaunchManifestNormalizer, McbbsModpackExportTask): replace LibraryAnalyzer with GameComponentAnalyzer for improved component analysis --- .../hmcl/game/LaunchManifestNormalizer.java | 25 ++++++------- .../hmcl/launch/LaunchClasspathResolver.java | 7 +--- .../modpack/mcbbs/McbbsModpackExportTask.java | 37 +++++++------------ 3 files changed, 28 insertions(+), 41 deletions(-) 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 b965d1e2298..ae1ef8ad900 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -17,7 +17,6 @@ */ package org.jackhuang.hmcl.game; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.util.SimpleMultimap; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.versioning.VersionNumber; @@ -85,44 +84,44 @@ private static GameInstanceManifest normalizeLaunchWrapper( // restored in deterministic order. if (analyzer.has(GameComponentType.LITELOADER) && !analyzer.hasModLauncher()) { builder.replaceTweakClass( - LibraryAnalyzer.LITELOADER_TWEAKER, - LibraryAnalyzer.LITELOADER_TWEAKER, + GameComponentAnalyzer.LITELOADER_TWEAKER, + GameComponentAnalyzer.LITELOADER_TWEAKER, !reorderTweakClass, reorderTweakClass); } else { - builder.removeTweakClass(LibraryAnalyzer.LITELOADER_TWEAKER); + builder.removeTweakClass(GameComponentAnalyzer.LITELOADER_TWEAKER); } if (analyzer.has(GameComponentType.OPTIFINE)) { if (!analyzer.has(GameComponentType.LITELOADER) && !analyzer.has(GameComponentType.FORGE)) { - if (builder.hasTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[1])) { + if (builder.hasTweakClass(GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(1))) { builder.replaceTweakClass( - LibraryAnalyzer.OPTIFINE_TWEAKERS[1], - LibraryAnalyzer.OPTIFINE_TWEAKERS[0], + GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(1), + GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(0), !reorderTweakClass, reorderTweakClass); } } else if (analyzer.hasModLauncher()) { mainClass = GameComponentAnalyzer.MOD_LAUNCHER_MAIN; - for (String optiFineTweaker : LibraryAnalyzer.OPTIFINE_TWEAKERS) { + for (String optiFineTweaker : GameComponentAnalyzer.OPTIFINE_TWEAKERS) { builder.removeTweakClass(optiFineTweaker); } - } else if (builder.hasTweakClass(LibraryAnalyzer.OPTIFINE_TWEAKERS[0])) { + } else if (builder.hasTweakClass(GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(0))) { builder.replaceTweakClass( - LibraryAnalyzer.OPTIFINE_TWEAKERS[0], - LibraryAnalyzer.OPTIFINE_TWEAKERS[1], + GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(0), + GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(1), !reorderTweakClass, reorderTweakClass); } } else { - for (String optiFineTweaker : LibraryAnalyzer.OPTIFINE_TWEAKERS) { + for (String optiFineTweaker : GameComponentAnalyzer.OPTIFINE_TWEAKERS) { builder.removeTweakClass(optiFineTweaker); } } boolean hasForge = analyzer.has(GameComponentType.FORGE); boolean hasModLauncher = analyzer.hasModLauncher(); - for (String forgeTweaker : LibraryAnalyzer.FORGE_TWEAKERS) { + for (String forgeTweaker : GameComponentAnalyzer.FORGE_TWEAKERS) { if (!hasForge) { builder.removeTweakClass(forgeTweaker); } else if (!hasModLauncher && builder.hasTweakClass(forgeTweaker)) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java index 6a4356a0b66..5daed09cb0d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java @@ -17,7 +17,6 @@ */ package org.jackhuang.hmcl.launch; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.NotNullByDefault; @@ -28,9 +27,7 @@ import java.util.LinkedHashSet; import java.util.Set; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.FORGE; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.LITELOADER; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.OPTIFINE; +import static org.jackhuang.hmcl.game.GameComponentType.*; /// Resolves the library classpath used for one launch attempt. @NotNullByDefault @@ -53,7 +50,7 @@ public static Set resolve( GameRepository repository, GameInstanceManifest manifest) { Set classpath = new LinkedHashSet<>(repository.getClasspath(manifest)); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(manifest, null); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); if (!analyzer.has(OPTIFINE) || (!analyzer.has(LITELOADER) && !analyzer.has(FORGE))) { return classpath; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java index 381249134b7..3e649fd15db 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java @@ -17,8 +17,9 @@ */ package org.jackhuang.hmcl.modpack.mcbbs; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.Library; import org.jackhuang.hmcl.modpack.ModAdviser; import org.jackhuang.hmcl.modpack.Modpack; @@ -41,8 +42,9 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.*; +import static org.jackhuang.hmcl.game.GameComponentType.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; /// Exports one registered game instance as an MCBBS modpack archive. @@ -107,27 +109,16 @@ public void execute() throws Exception { throw new IOException("Cannot parse the version of " + instanceId); } String gameVersion = version.toString(); - LibraryAnalyzer analyzer = LibraryAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); // Mcbbs manifest List addons = new ArrayList<>(); - addons.add(new McbbsModpackManifest.Addon(MINECRAFT.getPatchId(), gameVersion)); - analyzer.getVersion(FORGE).ifPresent(forgeVersion -> - addons.add(new McbbsModpackManifest.Addon(FORGE.getPatchId(), forgeVersion))); - analyzer.getVersion(CLEANROOM).ifPresent(cleanroomVersion -> - addons.add(new McbbsModpackManifest.Addon(CLEANROOM.getPatchId(), cleanroomVersion))); - analyzer.getVersion(NEO_FORGE).ifPresent(neoForgeVersion -> - addons.add(new McbbsModpackManifest.Addon(NEO_FORGE.getPatchId(), neoForgeVersion))); - analyzer.getVersion(LITELOADER).ifPresent(liteLoaderVersion -> - addons.add(new McbbsModpackManifest.Addon(LITELOADER.getPatchId(), liteLoaderVersion))); - analyzer.getVersion(OPTIFINE).ifPresent(optifineVersion -> - addons.add(new McbbsModpackManifest.Addon(OPTIFINE.getPatchId(), optifineVersion))); - analyzer.getVersion(FABRIC).ifPresent(fabricVersion -> - addons.add(new McbbsModpackManifest.Addon(FABRIC.getPatchId(), fabricVersion))); - analyzer.getVersion(QUILT).ifPresent(quiltVersion -> - addons.add(new McbbsModpackManifest.Addon(QUILT.getPatchId(), quiltVersion))); - analyzer.getVersion(LEGACY_FABRIC).ifPresent(legacyfabricVersion -> - addons.add(new McbbsModpackManifest.Addon(LEGACY_FABRIC.getPatchId(), legacyfabricVersion))); + addons.add(new McbbsModpackManifest.Addon(GAME.getPatchId(), gameVersion)); + for (GameComponentAnalyzer.Mark mark : analyzer) { + if ((mark.componentType().isModLoader() || mark.componentType() == GameComponentType.OPTIFINE)) { + addons.add(new McbbsModpackManifest.Addon(mark.componentType().getPatchId(), mark.version())); + } + } List libraries = new ArrayList<>(); // TODO libraries @@ -143,9 +134,9 @@ public void execute() throws Exception { // CurseForge manifest List modLoaders = new ArrayList<>(); - analyzer.getVersion(FORGE).ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("forge-" + forgeVersion, true))); - analyzer.getVersion(NEO_FORGE).ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("neoforge-" + forgeVersion, true))); - analyzer.getVersion(FABRIC).ifPresent(fabricVersion -> modLoaders.add(new CurseManifestModLoader("fabric-" + fabricVersion, true))); + Optional.ofNullable(analyzer.getVersion(FORGE)).ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("forge-" + forgeVersion, true))); + Optional.ofNullable(analyzer.getVersion(NEO_FORGE)).ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("neoforge-" + forgeVersion, true))); + Optional.ofNullable(analyzer.getVersion(FABRIC)).ifPresent(fabricVersion -> modLoaders.add(new CurseManifestModLoader("fabric-" + fabricVersion, true))); // OptiFine and LiteLoader are not supported by CurseForge modpack. CurseManifest curseManifest = new CurseManifest(CurseManifest.MINECRAFT_MODPACK, 1, info.getName(), info.getVersion(), info.getAuthor(), "overrides", new CurseManifestMinecraft(gameVersion, modLoaders), Collections.emptyList()); zip.putTextFile(JsonUtils.GSON.toJson(curseManifest), "manifest.json"); From fdb875e11cb875d02ce4ff290c211882fc8d0c68 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:58:33 +0800 Subject: [PATCH 111/199] feat(DefaultDependencyManager, DownloadPage, GameComponentAnalyzer, InstallerListPage, JavaManager, UpdateInstallerWizardProvider): replace LibraryAnalyzer with GameComponentAnalyzer for improved library and component handling --- .../org/jackhuang/hmcl/java/JavaManager.java | 3 +- .../UpdateInstallerWizardProvider.java | 9 ++-- .../hmcl/ui/instances/DownloadPage.java | 8 +--- .../hmcl/ui/instances/InstallerListPage.java | 4 +- .../download/DefaultDependencyManager.java | 8 ++-- .../hmcl/download/LibraryAnalyzer.java | 48 ------------------- .../hmcl/game/GameComponentAnalyzer.java | 39 +++++++++++++++ 7 files changed, 54 insertions(+), 65 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java index 96b33964e9b..ac92144c250 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java @@ -27,6 +27,7 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.LibraryAnalyzer; +import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.GameJavaVersion; import org.jackhuang.hmcl.game.JavaVersionConstraint; import org.jackhuang.hmcl.game.GameInstanceManifest; @@ -321,7 +322,7 @@ public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameIn @Nullable public static JavaRuntime findSuitableJava(Collection javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) { - LibraryAnalyzer analyzer = manifest != null ? LibraryAnalyzer.analyze(manifest, gameVersion != null ? gameVersion.toString() : null) : null; + GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion != null ? gameVersion.toString() : null) : null; boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64 && (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java index 5136dafb002..cc81c7e1fba 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java @@ -21,6 +21,7 @@ import org.jackhuang.hmcl.download.*; import org.jackhuang.hmcl.download.game.GameAssetIndexDownloadTask; import org.jackhuang.hmcl.download.game.LibraryDownloadException; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.setting.DownloadProviders; @@ -83,7 +84,7 @@ public Object finish(SettingsMap settings) { hints.add(new Task.StagesHint("hmcl.install.assets")); } } else if (value instanceof RemoveVersionAction removeVersionAction) { - ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, removeVersionAction.libraryId)); + ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, removeVersionAction.componentType)); } } @@ -177,10 +178,10 @@ public static void alertFailureMessage(Exception exception, Runnable next) { } public static class RemoveVersionAction { - private final String libraryId; + private final GameComponentType componentType; - public RemoveVersionAction(String libraryId) { - this.libraryId = libraryId; + public RemoveVersionAction(GameComponentType componentType) { + this.componentType = componentType; } } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java index e9469f15dab..1dba09c1b75 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java @@ -30,11 +30,7 @@ import javafx.scene.layout.*; import javafx.stage.FileChooser; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameInstance; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.RemoteAddonRepository; @@ -278,7 +274,7 @@ protected DownloadPageSkin(DownloadPage control) { if (gameVersion != null && control.versions.containsKey(gameVersion)) { List modVersions = control.versions.get(gameVersion); if (modVersions != null && !modVersions.isEmpty()) { - Set targetLoaders = LibraryAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders(); + Set targetLoaders = GameComponentAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders(); resolve: for (RemoteAddon.Version modVersion : modVersions) { 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 0e8d8c4f1bd..a2a7a7f9ebd 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java @@ -108,7 +108,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, item.getComponentType().getPatchId(), libraryVersion)); }); - item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), item.getComponentType().getPatchId()) + item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), item.getComponentType()) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) @@ -123,7 +123,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM); installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false)); - installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), mark.componentType().getPatchId()) + installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), mark.componentType()) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) 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 d9ae4a6c3b9..5ba55265c70 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -184,7 +184,7 @@ public Task installLibraryAsync(String gameVersion, GameIn public Task installLibraryAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion) { AtomicReference removedLibraryManifest = new AtomicReference<>(); - return removeLibraryAsync(baseVersion, libraryVersion.getLibraryId()) + return removeLibraryAsync(baseVersion, libraryVersion.getComponentType()) .thenComposeAsync(manifest -> { removedLibraryManifest.set(manifest); return libraryVersion.getInstallTask(this, manifest, modsDirectoryFor(manifest)); @@ -260,14 +260,14 @@ public UnsupportedLibraryInstallerException() { /// Creates a task that removes a loader's libraries and patch from a manifest. /// /// @param manifest the unresolved instance manifest - /// @param libraryId the patch identifier, such as `forge`, `optifine`, or `fabric` + /// @param componentType the patch identifier, such as `forge`, `optifine`, or `fabric` /// @return the task producing the updated independent manifest - public Task removeLibraryAsync(GameInstanceManifest manifest, String libraryId) { + public Task removeLibraryAsync(GameInstanceManifest manifest, GameComponentType componentType) { // Library removal operates on a standalone manifest so inherited launch metadata is retained. return Task.supplyAsync(() -> { GameInstanceManifest independentVersion = repository.resolve(manifest).standaloneManifest(); String gameVersion = repository.getGameVersion(independentVersion).orElse(null); - return LibraryAnalyzer.analyze(independentVersion, gameVersion).removeLibrary(libraryId).build(); + return GameComponentAnalyzer.analyze(independentVersion, gameVersion).removeLibrary(componentType); }); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java index 604b357858a..352d2ed89b5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java @@ -98,54 +98,6 @@ public boolean hasModLauncher() { ); } - private GameInstanceManifest removingMatchedLibrary(GameInstanceManifest manifest, String libraryId) { - LibraryType type = LibraryType.fromPatchId(libraryId); - if (type == null) return manifest; - - List libraries = new ArrayList<>(); - List rawLibraries = manifest.getLibraries(); - for (Library library : rawLibraries) { - if (type.matchLibrary(library, rawLibraries)) { - // skip - } else { - libraries.add(library); - } - } - return manifest.withLibraries(libraries); - } - - private GameInstancePatch removingMatchedLibrary(GameInstancePatch patch, String libraryId) { - LibraryType type = LibraryType.fromPatchId(libraryId); - if (type == null) return patch; - - List libraries = new ArrayList<>(); - List rawLibraries = patch.getLibraries(); - for (Library library : rawLibraries) { - if (type.matchLibrary(library, rawLibraries)) { - // skip - } else { - libraries.add(library); - } - } - return patch.withLibraries(libraries); - } - - /** - * Remove library by library id - * - * @param libraryId patch id or "forge"/"optifine"/"liteloader"/"fabric"/"quilt"/"neoforge"/"cleanroom" - * @return this - */ - public LibraryAnalyzer removeLibrary(String libraryId) { - if (!has(libraryId)) return this; - GameInstanceManifest manifest = removingMatchedLibrary(this.manifest, libraryId); - this.manifest = manifest.withPatches(this.manifest.getPatches().stream() - .filter(patch -> !libraryId.equals(patch.id())) - .map(patch -> removingMatchedLibrary(patch, libraryId)) - .collect(Collectors.toList())); - return this; - } - public GameInstanceManifest build() { return manifest; } 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 2c740102c32..15192a0d4ea 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -102,6 +102,45 @@ public boolean hasModLauncher() { ); } + private static GameInstanceManifest removingMatchedLibrary(GameInstanceManifest manifest, GameComponentType type) { + List libraries = new ArrayList<>(); + List rawLibraries = manifest.getLibraries(); + for (Library library : rawLibraries) { + if (type.matchLibrary(library, rawLibraries)) { + // skip + } else { + libraries.add(library); + } + } + return manifest.withLibraries(libraries); + } + + private GameInstancePatch removingMatchedLibrary(GameInstancePatch patch, GameComponentType type) { + List libraries = new ArrayList<>(); + List rawLibraries = patch.getLibraries(); + for (Library library : rawLibraries) { + if (type.matchLibrary(library, rawLibraries)) { + // skip + } else { + libraries.add(library); + } + } + return patch.withLibraries(libraries); + } + + /// Remove library by library id + /// + /// @param componentType the patch identifier, such as `forge`, `optifine`, or `fabric` + /// @return this + public GameInstanceManifest removeLibrary(GameComponentType componentType) { + if (!has(componentType)) return manifest; + GameInstanceManifest manifest = removingMatchedLibrary(this.manifest, componentType); + return manifest.withPatches(this.manifest.getPatches().stream() + .filter(patch -> !componentType.getPatchId().equals(patch.id())) + .map(patch -> removingMatchedLibrary(patch, componentType)) + .toList()); + } + public @Nullable String getVersion(GameComponentType type) { Mark mark = components.get(type); return mark != null ? mark.version() : null; From 1efffbc8e6cd1cf360106ad705a1a89441a1be36 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 21:58:56 +0800 Subject: [PATCH 112/199] feat(FabricRemoteVersion, ForgeRemoteVersion, GameLibrariesTask, GameRemoteVersion, JavaManager, LegacyFabricAPIRemoteVersion, LegacyFabricRemoteVersion, LiteLoaderRemoteVersion, NeoForgeRemoteVersion, OptiFineInstallTask, OptiFineRemoteVersion, QuiltAPIRemoteVersion, QuiltRemoteVersion): remove LibraryAnalyzer for improved component handling --- .../org/jackhuang/hmcl/java/JavaManager.java | 1 - .../hmcl/download/LibraryAnalyzer.java | 411 ------------------ .../download/fabric/FabricRemoteVersion.java | 1 - .../download/forge/ForgeRemoteVersion.java | 1 - .../hmcl/download/game/GameLibrariesTask.java | 1 - .../hmcl/download/game/GameRemoteVersion.java | 1 - .../LegacyFabricAPIRemoteVersion.java | 1 - .../LegacyFabricRemoteVersion.java | 1 - .../liteloader/LiteLoaderRemoteVersion.java | 1 - .../neoforge/NeoForgeRemoteVersion.java | 1 - .../optifine/OptiFineInstallTask.java | 1 - .../optifine/OptiFineRemoteVersion.java | 1 - .../download/quilt/QuiltAPIRemoteVersion.java | 1 - .../download/quilt/QuiltRemoteVersion.java | 1 - 14 files changed, 424 deletions(-) delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java index ac92144c250..45f1fb975c7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java @@ -26,7 +26,6 @@ import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.GameJavaVersion; import org.jackhuang.hmcl.game.JavaVersionConstraint; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java deleted file mode 100644 index 352d2ed89b5..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LibraryAnalyzer.java +++ /dev/null @@ -1,411 +0,0 @@ -/* - * Hello Minecraft! Launcher - * Copyright (C) 2020 huangyuhui and contributors - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package org.jackhuang.hmcl.download; - -import org.intellij.lang.annotations.Language; -import org.jackhuang.hmcl.game.*; -import org.jackhuang.hmcl.addon.mod.ModLoaderType; -import org.jackhuang.hmcl.util.Pair; -import org.jackhuang.hmcl.util.versioning.VersionNumber; -import org.jackhuang.hmcl.util.versioning.VersionRange; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.*; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -import java.util.stream.Collectors; - -import static org.jackhuang.hmcl.util.Pair.pair; - -public final class LibraryAnalyzer implements Iterable { - private GameInstanceManifest manifest; - private final Map> libraries; - - private LibraryAnalyzer(GameInstanceManifest manifest, Map> libraries) { - this.manifest = manifest; - this.libraries = libraries; - } - - public Optional getVersion(LibraryType type) { - return getVersion(type.getPatchId()); - } - - public Optional getVersion(String type) { - return Optional.ofNullable(libraries.get(type)).map(Pair::getValue); - } - - /** - * If a library is provided in $.patches, it's structure is so clear that we can do any operation. - * Otherwise, we must guess how are these libraries mixed. - * Maybe a guessing implementation will be provided in the future. But by now, we simply set it to JUST_EXISTED. - */ - public LibraryMark.LibraryStatus getLibraryStatus(String type) { - return manifest.hasPatch(type) ? LibraryMark.LibraryStatus.CLEAR : LibraryMark.LibraryStatus.JUST_EXISTED; - } - - @NotNull - @Override - public Iterator iterator() { - return new Iterator() { - Iterator>> impl = libraries.entrySet().iterator(); - - @Override - public boolean hasNext() { - return impl.hasNext(); - } - - @Override - public LibraryMark next() { - Map.Entry> entry = impl.next(); - return new LibraryMark(entry.getKey(), entry.getValue().getValue(), getLibraryStatus(entry.getKey())); - } - }; - } - - public boolean has(LibraryType type) { - return has(type.getPatchId()); - } - - public boolean has(String type) { - return libraries.containsKey(type); - } - - public boolean hasModLoader() { - return libraries.keySet().stream().map(LibraryType::fromPatchId) - .filter(Objects::nonNull) - .anyMatch(LibraryType::isModLoader); - } - - public boolean hasModLauncher() { - return GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()) || manifest.getPatches().stream().anyMatch( - patch -> GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(patch.mainClass()) - ); - } - - public GameInstanceManifest build() { - return manifest; - } - - public static LibraryAnalyzer analyze(GameInstanceManifest.Resolved resolved, String gameVersion) { - Map> libraries = new HashMap<>(); - - if (gameVersion != null) { - libraries.put(LibraryType.MINECRAFT.getPatchId(), pair(null, gameVersion)); - } - - List rawLibraries = resolved.launchManifest().getLibraries(); - for (Library library : rawLibraries) { - for (LibraryType type : LibraryType.values()) { - if (type.matchLibrary(library, rawLibraries)) { - libraries.put(type.getPatchId(), pair(library, type.patchVersion(resolved.standaloneManifest(), library.version()))); - break; - } - } - } - - for (GameInstancePatch patch : resolved.standaloneManifest().getPatches()) { - if (patch.isHidden()) continue; - libraries.put(patch.id(), pair(null, patch.version())); - } - - return new LibraryAnalyzer(resolved.standaloneManifest(), libraries); - } - - public static LibraryAnalyzer analyze(GameInstanceManifest manifest, String gameVersion) { - if (manifest.inheritsFrom() != null) - throw new IllegalArgumentException("LibraryAnalyzer can only analyze independent game version"); - - Map> libraries = new HashMap<>(); - - if (gameVersion != null) { - libraries.put(LibraryType.MINECRAFT.getPatchId(), pair(null, gameVersion)); - } - - List rawLibraries = manifest.getLibraries(); - for (Library library : rawLibraries) { - for (LibraryType type : LibraryType.values()) { - if (type.matchLibrary(library, rawLibraries)) { - libraries.put(type.getPatchId(), pair(library, type.patchVersion(manifest, library.version()))); - break; - } - } - } - - for (GameInstancePatch patch : manifest.getPatches()) { - if (patch.isHidden()) continue; - libraries.put(patch.id(), pair(null, patch.version())); - } - - return new LibraryAnalyzer(manifest, libraries); - } - - public Set getModLoaders() { - return Arrays.stream(LibraryType.values()) - .filter(LibraryType::isModLoader) - .filter(this::has) - .map(LibraryType::getModLoaderType) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); - } - - public enum LibraryType { - MINECRAFT(true, "game", "^$", "^$", null), - LEGACY_FABRIC(true, "legacyfabric", "net\\.fabricmc", "fabric-loader", ModLoaderType.LEGACY_FABRIC) { - @Override - protected boolean matchLibrary(Library library, List libraries) { - if (!super.matchLibrary(library, libraries)) { - return false; - } - for (Library l : libraries) { - if ("net.legacyfabric".equals(l.groupId())) { - return true; - } - } - return false; - } - }, - LEGACY_FABRIC_API(false, "legacyfabric-api", "net\\.legacyfabric", "legacyfabric-api", null), - FABRIC(true, "fabric", "net\\.fabricmc", "fabric-loader", ModLoaderType.FABRIC) { - @Override - protected boolean matchLibrary(Library library, List libraries) { - if (!super.matchLibrary(library, libraries)) { - return false; - } - for (Library l : libraries) { - if ("net.legacyfabric".equals(l.groupId())) { - return false; - } - } - return true; - } - }, - FABRIC_API(true, "fabric-api", "net\\.fabricmc", "fabric-api", null), - FORGE(true, "forge", "net\\.minecraftforge", "(forge|fmlloader)", ModLoaderType.FORGE) { - private final Pattern FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?[0-9.]+)(-([0-9.]+))?$"); - - @Override - protected String patchVersion(GameInstanceManifest manifest, String libraryVersion) { - Matcher matcher = FORGE_VERSION_MATCHER.matcher(libraryVersion); - if (matcher.find()) { - return matcher.group("forge"); - } - return super.patchVersion(manifest, libraryVersion); - } - - @Override - protected boolean matchLibrary(Library library, List libraries) { - for (Library l : libraries) { - if (NEO_FORGE.matchLibrary(l, libraries)) { - return false; - } - } - return super.matchLibrary(library, libraries); - } - }, - CLEANROOM(true, "cleanroom", "com\\.cleanroommc", "cleanroom", ModLoaderType.CLEANROOM), - NEO_FORGE(true, "neoforge", "net\\.neoforged\\.fancymodloader", "(core|loader)", ModLoaderType.NEO_FORGE) { - private final Pattern NEO_FORGE_VERSION_MATCHER = Pattern.compile("^([0-9.]+)-(?[0-9.]+)(-([0-9.]+))?$"); - - @Override - protected String patchVersion(GameInstanceManifest manifest, String libraryVersion) { - String res = scanVersion(manifest); - if (res != null) { - return res; - } - - for (GameInstancePatch patch : manifest.getPatches()) { - res = scanPatch(patch); - if (res != null) { - return res; - } - } - - Matcher matcher = NEO_FORGE_VERSION_MATCHER.matcher(libraryVersion); - if (matcher.find()) { - return matcher.group("forge"); - } - - return super.patchVersion(manifest, libraryVersion); - } - - private String scanVersion(GameInstanceManifest manifest) { - if (manifest.arguments() == null) { - return null; - } - List gameArguments = manifest.arguments().game(); - if (gameArguments == null) { - return null; - } - - for (int i = 0; i < gameArguments.size() - 1; i++) { - Argument argument = gameArguments.get(i); - if (argument instanceof StringArgument) { - String argumentValue = ((StringArgument) argument).argument(); - if ("--fml.neoForgeVersion".equals(argumentValue) || "--fml.forgeVersion".equals(argumentValue)) { - Argument next = gameArguments.get(i + 1); - if (next instanceof StringArgument) { - return ((StringArgument) next).argument(); - } - return null; // Normally, there should not be two --fml.neoForgeVersion argument. - } - } - } - return null; - } - - private String scanPatch(GameInstancePatch patch) { - Arguments optArgument = patch.arguments(); - if (optArgument == null) { - return null; - } - List gameArguments = optArgument.game(); - if (gameArguments == null) { - return null; - } - - for (int i = 0; i < gameArguments.size() - 1; i++) { - Argument argument = gameArguments.get(i); - if (argument instanceof StringArgument) { - String argumentValue = ((StringArgument) argument).argument(); - if ("--fml.neoForgeVersion".equals(argumentValue) || "--fml.forgeVersion".equals(argumentValue)) { - Argument next = gameArguments.get(i + 1); - if (next instanceof StringArgument) { - return ((StringArgument) next).argument(); - } - return null; - } - } - } - return null; - } - - }, - LITELOADER(true, "liteloader", "com\\.mumfrey", "liteloader", ModLoaderType.LITE_LOADER), - OPTIFINE(false, "optifine", "(net\\.)?optifine", "^(?!.*launchwrapper).*$", null), - QUILT(true, "quilt", "org\\.quiltmc", "quilt-loader", ModLoaderType.QUILT), - QUILT_API(true, "quilt-api", "org\\.quiltmc", "quilt-api", null), - BOOTSTRAP_LAUNCHER(false, "", "cpw\\.mods", "bootstraplauncher", null); - - private final boolean modLoader; - private final String patchId; - private final Pattern group, artifact; - private final ModLoaderType modLoaderType; - - private static final Map PATCH_ID_MAP = new HashMap<>(); - - static { - for (LibraryType type : values()) { - PATCH_ID_MAP.put(type.getPatchId(), type); - } - } - - LibraryType(boolean modLoader, String patchId, @Language("RegExp") String group, @Language("RegExp") String artifact, ModLoaderType modLoaderType) { - this.modLoader = modLoader; - this.patchId = patchId; - this.group = Pattern.compile(group); - this.artifact = Pattern.compile(artifact); - this.modLoaderType = modLoaderType; - } - - public boolean isModLoader() { - return modLoader; - } - - public String getPatchId() { - return patchId; - } - - public ModLoaderType getModLoaderType() { - return modLoaderType; - } - - public static LibraryType fromPatchId(String patchId) { - return PATCH_ID_MAP.get(patchId); - } - - protected boolean matchLibrary(Library library, List libraries) { - return group.matcher(library.groupId()).matches() && artifact.matcher(library.artifactId()).matches(); - } - - protected String patchVersion(GameInstanceManifest manifest, String libraryVersion) { - return libraryVersion; - } - } - - public final static class LibraryMark { - /** - * If a library is provided in $.patches, it's structure is so clear that we can do any operation. - * Otherwise, we must guess how are these libraries mixed. - * Maybe a guessing implementation will be provided in the future. But by now, we simply set it to JUST_EXISTED. - */ - public enum LibraryStatus { - CLEAR, UNSURE, JUST_EXISTED - } - - private final String libraryId; - private final String libraryVersion; - /** - * If this version is installed by HMCL, instead of external process, - * which means $.patches contains this library, structureClear is true. - */ - private final LibraryStatus status; - - private LibraryMark(@NotNull String libraryId, @Nullable String libraryVersion, LibraryStatus status) { - this.libraryId = libraryId; - this.libraryVersion = libraryVersion; - this.status = status; - } - - @NotNull - public String getLibraryId() { - return libraryId; - } - - @Nullable - public String getLibraryVersion() { - return libraryVersion; - } - - public LibraryStatus getStatus() { - return status; - } - } - - public static final Set FORGE_OPTIFINE_MAIN = Set.of( - GameComponentAnalyzer.VANILLA_MAIN, - GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN, - GameComponentAnalyzer.MOD_LAUNCHER_MAIN, - GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN, - GameComponentAnalyzer.FORGE_BOOTSTRAP_MAIN, - GameComponentAnalyzer.NEO_FORGE_BOOTSTRAP_MAIN - ); - - public static final VersionRange FORGE_OPTIFINE_BROKEN_RANGE = VersionNumber.between("48.0.0", "49.0.50"); - - public static final String[] FORGE_TWEAKERS = new String[]{ - "net.minecraftforge.legacy._1_5_2.LibraryFixerTweaker", // 1.5.2 - "cpw.mods.fml.common.launcher.FMLTweaker", // 1.6.1 ~ 1.7.10 - "net.minecraftforge.fml.common.launcher.FMLTweaker" // 1.8 ~ 1.12.2 - }; - public static final String[] OPTIFINE_TWEAKERS = new String[]{ - "optifine.OptiFineTweaker", - "optifine.OptiFineForgeTweaker" - }; - public static final String LITELOADER_TWEAKER = "com.mumfrey.liteloader.launch.LiteLoaderTweaker"; -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricRemoteVersion.java index e8d0231fee6..a95fdd8ab1b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricRemoteVersion.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.fabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; 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 320eb08bfb0..f93607092c0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeRemoteVersion.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.forge; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; 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 30e13be0ee8..ab2f224f3ef 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.AbstractDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; 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 725030fb4ea..5d3dcfc68df 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameRemoteVersion.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; 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 8f80cb69566..c93ed578175 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricAPIRemoteVersion.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.legacyfabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; 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 4a6827dd3db..efb24bee0de 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/legacyfabric/LegacyFabricRemoteVersion.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.legacyfabric; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; 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 c81f66609eb..f6a09e79b8e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/liteloader/LiteLoaderRemoteVersion.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.liteloader; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; 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 7a992bb34ae..4b3483f9610 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeRemoteVersion.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.neoforge; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; 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 f963246a416..b24032b8867 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.optifine; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.UnsupportedInstallationException; import org.jackhuang.hmcl.download.VersionMismatchException; import org.jackhuang.hmcl.game.*; 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 98278d20dc0..6e527984393 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.optifine; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; 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 840107df6c4..96d46ef9a87 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltAPIRemoteVersion.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.quilt; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; 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 1ad8faf0022..4385fb39077 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltRemoteVersion.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.download.quilt; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LibraryAnalyzer; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; From c7e53784ef520ebfe975479e0c5af6ba9f3b8e9d Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 22:03:20 +0800 Subject: [PATCH 113/199] feat(AdditionalInstallersPage, GameInstallTask, InstallerItem, InstallersPage, ServerModpackManifest): replace libraryId with componentType.getPatchId for improved consistency in library handling --- .../org/jackhuang/hmcl/ui/InstallerItem.java | 17 ++--------------- .../ui/download/AdditionalInstallersPage.java | 11 ++++++----- .../hmcl/ui/download/InstallersPage.java | 2 +- .../hmcl/download/game/GameInstallTask.java | 5 ++--- .../jackhuang/hmcl/game/GameComponentType.java | 2 ++ .../modpack/server/ServerModpackManifest.java | 5 ++--- 6 files changed, 15 insertions(+), 27 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java index 84fb5d465a3..cb4e2be088b 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java @@ -41,10 +41,7 @@ import org.jackhuang.hmcl.util.i18n.I18n; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; +import java.util.*; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -53,7 +50,6 @@ */ public class InstallerItem extends Control { private final GameComponentType type; - private final String id; private final GameInstanceIconType iconType; private final Style style; private final ObjectProperty versionProperty = new SimpleObjectProperty<>(this, "version", null); @@ -83,10 +79,8 @@ public enum Style { CARD, } - public InstallerItem(GameComponentType type, Style style) { this.type = type; - this.id = type.getPatchId(); this.style = style; this.iconType = GameInstanceIconType.getIconType(type); } @@ -94,10 +88,6 @@ public InstallerItem(GameComponentType type, Style style) { public GameComponentType getComponentType() { return type; } - - public String getLibraryId() { - return id; - } public ObjectProperty versionProperty() { return versionProperty; @@ -225,10 +215,7 @@ public InstallerItemGroup(GameVersionNumber gameVersion, Style style) { if (!item.resolvedStateProperty.isBound()) { item.resolvedStateProperty.bind(Bindings.createObjectBinding(() -> { InstalledState itemVersion = item.versionProperty.get(); - if (itemVersion != null) { - return itemVersion; - } - return InstallableState.INSTANCE; + return Objects.requireNonNullElse(itemVersion, InstallableState.INSTANCE); }, item.versionProperty)); } } 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 536d95dfba0..ea6124c7fc7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java @@ -51,10 +51,11 @@ public AdditionalInstallersPage(String gameVersion, GameInstanceManifest manifes txtName.setEditable(false); for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); - if (libraryId.equals("game")) continue; + if (library.getComponentType() == GameComponentType.GAME) continue; library.setOnRemove(() -> { - controller.getSettings().put(libraryId, new UpdateInstallerWizardProvider.RemoveVersionAction(libraryId)); + controller.getSettings().put( + library.getComponentType().getPatchId(), + new UpdateInstallerWizardProvider.RemoveVersionAction(library.getComponentType())); reload(); }); } @@ -88,11 +89,11 @@ protected void reload() { boolean compatible = true; for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); + String libraryId = library.getComponentType().getPatchId(); String version = analyzer.getVersion(library.getComponentType()); String libraryVersion = Lang.requireNonNullElse(getVersion(libraryId), version); boolean alreadyInstalled = version != null && !(controller.getSettings().get(libraryId) instanceof UpdateInstallerWizardProvider.RemoveVersionAction); - if (!"game".equals(libraryId) && currentGameVersion != null && !currentGameVersion.equals(game) && getVersion(libraryId) == null && alreadyInstalled) { + if (library.getComponentType() != GameComponentType.GAME && currentGameVersion != null && !currentGameVersion.equals(game) && getVersion(libraryId) == null && alreadyInstalled) { // For third-party libraries, if game version is being changed, and the library is not being reinstalled, // warns the user that we should update the library. library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, true)); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java index b0e4498377b..16d0abdde9a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java @@ -61,7 +61,7 @@ private String getVersion(String id) { protected void reload() { for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getLibraryId(); + String libraryId = library.getComponentType().getPatchId(); if (controller.getSettings().containsKey(libraryId)) { library.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false)); } else { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java index 84597510715..55e03b8aaae 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java @@ -19,6 +19,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; @@ -29,8 +30,6 @@ import java.util.Collections; import java.util.List; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; - public class GameInstallTask extends Task { private final DefaultGameRepository gameRepository; @@ -67,7 +66,7 @@ public boolean isRelyingOnDependencies() { public void execute() throws Exception { GameInstancePatch patch = GameInstancePatch.fromManifest( JsonUtils.fromNonNullJson(downloadTask.getResult(), GameInstanceManifest.class), - MINECRAFT.getPatchId(), + GameComponentType.GAME.getPatchId(), remote.getGameVersion(), GameInstancePatch.PRIORITY_MC).withJar(null); setResult(patch); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java index b6ed1e4c712..9abe965ad91 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -252,6 +253,7 @@ public boolean isModLoader() { return modLoaderType != null; } + @Contract(pure = true) public String getPatchId() { return patchId; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java index 41a44b7b1b1..58dcc947b38 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java @@ -19,6 +19,7 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; @@ -34,8 +35,6 @@ import java.util.Collections; import java.util.List; -import static org.jackhuang.hmcl.download.LibraryAnalyzer.LibraryType.MINECRAFT; - public class ServerModpackManifest implements ModpackManifest, Validation { private final String name; private final String author; @@ -123,7 +122,7 @@ public String getVersion() { } public Modpack toModpack(Charset encoding) throws IOException { - String gameVersion = addons.stream().filter(x -> MINECRAFT.getPatchId().equals(x.id)).findAny() + String gameVersion = addons.stream().filter(x -> GameComponentType.GAME.getPatchId().equals(x.id)).findAny() .orElseThrow(() -> new IOException("Cannot find game version")).getVersion(); return new Modpack(name, author, version, gameVersion, description, encoding, this) { @Override From a85284f7e4f1223e3d239a1dec88e8297ee74bfa Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 7 Aug 2026 22:06:38 +0800 Subject: [PATCH 114/199] refactor(GameComponentAnalyzer, GameInstanceManifestTest, GameInstancePatch, HMCLGameInstance, HMCLGameRepository, NativePatcher): clean up code and improve readability by removing unnecessary lines and adjusting comments --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 18 ++++++------------ .../hmcl/game/HMCLGameRepository.java | 2 -- .../org/jackhuang/hmcl/util/NativePatcher.java | 1 - .../hmcl/game/GameComponentAnalyzer.java | 1 - .../jackhuang/hmcl/game/GameInstancePatch.java | 12 ++++-------- .../hmcl/game/GameInstanceManifestTest.java | 2 +- 6 files changed, 11 insertions(+), 25 deletions(-) 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 2283dcd1998..c95a3f39880 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -43,7 +43,6 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; -import java.lang.ref.SoftReference; import java.lang.ref.WeakReference; import java.nio.file.Files; import java.nio.file.Path; @@ -386,12 +385,12 @@ private void clearIconFiles() { /// Soft-cached icon image for this instance id. /// /// Shared across COW snapshot wrappers. The computed [Image] is retained only via a - /// [SoftReference], so it can be reclaimed under memory pressure when nothing else holds it. + /// [WeakReference], so it can be reclaimed under memory pressure when nothing else holds it. private @Nullable WeakCachedIconImageProperty iconImage; /// Returns the observable icon image for this instance. /// - /// The image is stored in a [SoftReference] cache: when nothing else strongly references it + /// The image is stored in a [WeakReference] cache: when nothing else strongly references it /// (for example no UI node is displaying it), the JVM may reclaim the [Image] under memory /// pressure. The next [#getIconImage] reloads it. /// @@ -696,15 +695,10 @@ private static LoadResult loadGameSettingsFile(Path file) { JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA); switch (schemaResult.status()) { case MISSING -> LOG.warning("Missing schema in instance game settings: " + file); - case INVALID -> LOG.warning("Invalid schema in instance game settings: " - + file + ", Actual: " + schemaResult.invalidValue()); - case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: " - + file + ", Actual: " + schemaResult.actual()); - case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: " - + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); - case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> - LOG.warning("Unsupported instance game settings schema. Expected: " - + GameSettings.Instance.CURRENT_SCHEMA + ", Actual: " + schemaResult.actual()); + case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue())); + case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual())); + case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); + case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { } } 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 f1d1f6aa6e8..449593e4665 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -542,6 +542,4 @@ public static long getAutoAllocatedMemory(long available) { 16L * 1024 * 1024 * 1024); return suggested; } - - } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java index 0221fca5b23..b248cc7fe62 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java @@ -29,7 +29,6 @@ import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; import java.io.IOException; 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 15192a0d4ea..5f846e23288 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -63,7 +63,6 @@ private static GameComponentAnalyzer analyze( return new GameComponentAnalyzer(standaloneManifest, components); } - public static GameComponentAnalyzer analyze(GameInstanceManifest.Resolved resolved, @Nullable String gameVersion) { return analyze(resolved.standaloneManifest(), resolved.launchManifest(), gameVersion); } 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 fc0810a6fdc..06fd1c6985b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstancePatch.java @@ -312,14 +312,10 @@ private static final class Builder { private @Nullable String assets; private @Nullable Integer complianceLevel; private @Nullable GameJavaVersion javaVersion; - private @Nullable - @Unmodifiable List libraries; - private @Nullable - @Unmodifiable List compatibilityRules; - private @Nullable - @Unmodifiable Map downloads; - private @Nullable - @Unmodifiable Map logging; + private @Nullable @Unmodifiable List libraries; + private @Nullable @Unmodifiable List compatibilityRules; + private @Nullable @Unmodifiable Map downloads; + private @Nullable @Unmodifiable Map logging; private @Nullable ReleaseType type; private @Nullable Instant time; private @Nullable Instant releaseTime; 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 0bd9c5f0fac..48c46b9c5ad 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/GameInstanceManifestTest.java @@ -166,7 +166,7 @@ public void testPatchParsingAndCopyBehavior() { GameInstancePatch patch = originalPatch .withMainClass("new.Main") - .withId(null); + .withId((String) null); JsonObject updatedJson = patch.toJsonObject(); assertEquals("value", updatedJson.get("unknownField").getAsString()); From 8e65d5b8e729c1dd1c686186e60ad227bc486b8b Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 15:43:00 +0800 Subject: [PATCH 115/199] refactor(GameComponentAnalyzer): mark components map as unmodifiable for improved safety --- .../java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 5f846e23288..472b8306495 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -75,9 +75,9 @@ public static GameComponentAnalyzer analyze(GameInstanceManifest manifest, @Null } private final GameInstanceManifest manifest; - private final Map components; + private final @Unmodifiable Map components; - private GameComponentAnalyzer(GameInstanceManifest manifest, Map components) { + private GameComponentAnalyzer(GameInstanceManifest manifest, @Unmodifiable Map components) { this.manifest = manifest; this.components = components; } From 3d46e60dd8e6bb92be449d575e66e4ee544f5748 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 15:51:40 +0800 Subject: [PATCH 116/199] refactor(DefaultDependencyManager, DefaultLauncher, GameComponentAnalyzer, GameInstance, GameLibrariesTask, GameVerificationFixTask, McbbsModpackExportTask, ModrinthModpackExportTask, MultiMCModpackExportTask, ServerModpackExportTask): streamline GameComponentAnalyzer usage and improve game version handling --- .../hmcl/download/DefaultDependencyManager.java | 7 ++++--- .../hmcl/download/game/GameLibrariesTask.java | 2 +- .../hmcl/download/game/GameVerificationFixTask.java | 2 +- .../org/jackhuang/hmcl/game/DefaultGameInstance.java | 5 +++++ .../jackhuang/hmcl/game/GameComponentAnalyzer.java | 11 ++++++----- .../java/org/jackhuang/hmcl/game/GameInstance.java | 2 ++ .../org/jackhuang/hmcl/launch/DefaultLauncher.java | 3 +-- .../hmcl/modpack/mcbbs/McbbsModpackExportTask.java | 2 +- .../modpack/modrinth/ModrinthModpackExportTask.java | 2 +- .../modpack/multimc/MultiMCModpackExportTask.java | 2 +- .../hmcl/modpack/server/ServerModpackExportTask.java | 2 +- 11 files changed, 24 insertions(+), 16 deletions(-) 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 5ba55265c70..767cbd02e9e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -134,8 +134,7 @@ public Task checkPatchCompletionAsync( GameInstanceManifest original = instance.getManifest(); GameInstanceManifest.Resolved resolvedInstanceManifest = instance.getResolvedManifest(); - - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedInstanceManifest, gameVersion); + GameComponentAnalyzer analyzer = instance.getAnalyzer(); for (GameComponentType type : GameComponentType.values()) { if (!analyzer.has(type)) continue; @@ -266,7 +265,9 @@ public Task removeLibraryAsync(GameInstanceManifest manife // Library removal operates on a standalone manifest so inherited launch metadata is retained. return Task.supplyAsync(() -> { GameInstanceManifest independentVersion = repository.resolve(manifest).standaloneManifest(); - String gameVersion = repository.getGameVersion(independentVersion).orElse(null); + GameVersionNumber gameVersion = repository.getGameVersion(independentVersion) + .map(GameVersionNumber::asGameVersion) + .orElse(null); return GameComponentAnalyzer.analyze(independentVersion, gameVersion).removeLibrary(componentType); }); } 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 ab2f224f3ef..65d68c386ac 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 @@ -163,7 +163,7 @@ public void execute() throws IOException { Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), library); if ("optifine".equals(library.groupId()) && Files.exists(file) && GameVersionNumber.asGameVersion(gameRepository.getGameVersion(manifest).orElse(null)).compareTo("1.20.4") == 0) { - @Nullable String forgeVersion = GameComponentAnalyzer.analyze(manifest, "1.20.4") + @Nullable String forgeVersion = GameComponentAnalyzer.analyze(manifest, GameVersionNumber.asGameVersion("1.20.4")) .getVersion(GameComponentType.FORGE); if (forgeVersion != null && GameComponentAnalyzer.FORGE_OPTIFINE_BROKEN_RANGE.contains(VersionNumber.asVersion(forgeVersion))) { try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(file)) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java index d31871026ea..d3d4ab67373 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java @@ -64,7 +64,7 @@ public GameVerificationFixTask(GameInstance instance, GameVersionNumber gameVers @Override public void execute() throws IOException { Path jar = instance.getInstanceJarFile(); - var analyzer = GameComponentAnalyzer.analyze(manifest, gameVersion.toString()); + var analyzer = instance.getAnalyzer(); if (Files.exists(jar) && gameVersion.compareTo("1.6") < 0 && analyzer.has(GameComponentType.FORGE)) { try (FileSystem fs = CompressingUtils.createWritableZipFileSystem(jar, StandardCharsets.UTF_8)) { 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 6ae68659e34..29ff781495e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -159,6 +159,11 @@ public GameInstanceManifest.Resolved getResolvedManifest() { return resolvedManifest; } + @Override + public GameComponentAnalyzer getAnalyzer() { + return null; + } + /// {@inheritDoc} /// /// The detected version is cached on this instance. When the primary jar cannot be resolved or 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 472b8306495..1daced7ba0d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.addon.mod.ModLoaderType; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jackhuang.hmcl.util.versioning.VersionRange; import org.jetbrains.annotations.NotNullByDefault; @@ -32,11 +33,11 @@ public final class GameComponentAnalyzer implements Iterable(GameComponentType.class); - if (gameVersion != null) { - components.put(GameComponentType.GAME, new Mark(GameComponentType.GAME, gameVersion, true)); + if (gameVersion != null && !gameVersion.equals(GameVersionNumber.unknown())) { + components.put(GameComponentType.GAME, new Mark(GameComponentType.GAME, gameVersion.toString(), true)); } for (GameInstancePatch patch : standaloneManifest.getPatches()) { @@ -63,11 +64,11 @@ private static GameComponentAnalyzer analyze( return new GameComponentAnalyzer(standaloneManifest, components); } - public static GameComponentAnalyzer analyze(GameInstanceManifest.Resolved resolved, @Nullable String gameVersion) { + public static GameComponentAnalyzer analyze(GameInstanceManifest.Resolved resolved, @Nullable GameVersionNumber gameVersion) { return analyze(resolved.standaloneManifest(), resolved.launchManifest(), gameVersion); } - public static GameComponentAnalyzer analyze(GameInstanceManifest manifest, @Nullable String gameVersion) { + public static GameComponentAnalyzer analyze(GameInstanceManifest manifest, @Nullable GameVersionNumber gameVersion) { if (manifest.inheritsFrom() != null) throw new IllegalArgumentException("LibraryAnalyzer can only analyze independent game version"); 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 18a8bc60cfd..28f0a63fdd0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -61,6 +61,8 @@ default GameInstanceManifest getLaunchManifest() { return getResolvedManifest().launchManifest(); } + GameComponentAnalyzer getAnalyzer(); + GameVersionNumber getVersion(); /// Returns the directory containing files owned by this instance. 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 4884db15c52..702ae2d54b8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -55,8 +55,7 @@ public DefaultLauncher(GameInstance instance, GameInstanceManifest manifest, Aut super(instance, manifest, authInfo, options, listener, daemon); GameVersionNumber version = instance.getVersion(); - this.analyzer = GameComponentAnalyzer.analyze(manifest, - version == GameVersionNumber.unknown() ? null : version.toString()); + this.analyzer = GameComponentAnalyzer.analyze(manifest, version); } private Command generateCommandLine(Path nativeFolder) throws IOException { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java index 3e649fd15db..a6e5daead65 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java @@ -109,7 +109,7 @@ public void execute() throws Exception { throw new IOException("Cannot parse the version of " + instanceId); } String gameVersion = version.toString(); - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); + GameComponentAnalyzer analyzer = instance.getAnalyzer(); // Mcbbs manifest List addons = new ArrayList<>(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java index 835ffccc323..eb3a0997c8e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackExportTask.java @@ -198,7 +198,7 @@ public void execute() throws Exception { throw new IOException("Cannot parse the version of " + instanceId); } String gameVersion = version.toString(); - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); + GameComponentAnalyzer analyzer = instance.getAnalyzer(); Map dependencies = new HashMap<>(); dependencies.put("minecraft", gameVersion); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java index 25a0d9a2962..a671f940815 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackExportTask.java @@ -93,7 +93,7 @@ public void execute() throws Exception { throw new IOException("Cannot parse the version of " + instanceId); } String gameVersion = version.toString(); - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); + GameComponentAnalyzer analyzer = instance.getAnalyzer(); List components = new ArrayList<>(); components.add(new MultiMCManifest.MultiMCManifestComponent(true, false, MultiMCComponents.getComponent(GameComponentType.GAME), gameVersion)); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java index b33e8c8ea22..03ba4de9f25 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java @@ -103,7 +103,7 @@ public void execute() throws Exception { throw new IOException("Cannot parse the version of " + instanceId); } String gameVersion = version.toString(); - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), gameVersion); + GameComponentAnalyzer analyzer = instance.getAnalyzer(); List addons = new ArrayList<>(); addons.add(new ServerModpackManifest.Addon(GameComponentType.GAME.getPatchId(), gameVersion)); From 0c5e26bbce13376d9301567d7b37590b9a1dcb16 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 15:57:39 +0800 Subject: [PATCH 117/199] refactor(AdditionalInstallersPage, DownloadPage, GameCrashWindow, GameItem, InstallerListPage, JavaManager, LauncherHelper): simplify GameComponentAnalyzer usage by leveraging existing game instance analyzers --- .../java/org/jackhuang/hmcl/game/LauncherHelper.java | 2 +- .../java/org/jackhuang/hmcl/java/JavaManager.java | 2 +- .../java/org/jackhuang/hmcl/ui/GameCrashWindow.java | 2 +- .../hmcl/ui/download/AdditionalInstallersPage.java | 3 ++- .../jackhuang/hmcl/ui/instances/DownloadPage.java | 12 +++++------- .../org/jackhuang/hmcl/ui/instances/GameItem.java | 2 +- .../hmcl/ui/instances/InstallerListPage.java | 2 +- 7 files changed, 12 insertions(+), 13 deletions(-) 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 4da6de1553b..6ff94f1cb8e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -437,7 +437,7 @@ public void onStop(boolean success, TaskExecutor executor) { } private static Task checkGameState(HMCLGameInstance gameInstance, GameSettings.Effective setting, GameInstanceManifest manifest) { - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion().toString()); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, gameInstance.getVersion()); GameVersionNumber gameVersion = gameInstance.getVersion(); Task getJavaTask = Task.supplyAsync(() -> { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java index 45f1fb975c7..402e67daeb9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/java/JavaManager.java @@ -321,7 +321,7 @@ public static JavaRuntime findSuitableJava(GameVersionNumber gameVersion, GameIn @Nullable public static JavaRuntime findSuitableJava(Collection javaRuntimes, GameVersionNumber gameVersion, GameInstanceManifest manifest) { - GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion != null ? gameVersion.toString() : null) : null; + GameComponentAnalyzer analyzer = manifest != null ? GameComponentAnalyzer.analyze(manifest, gameVersion) : null; boolean forceX86 = Architecture.SYSTEM_ARCH == Architecture.ARM64 && (OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS || OperatingSystem.CURRENT_OS == OperatingSystem.MACOS) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index 18488d36eb5..d21fc711328 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -97,7 +97,7 @@ public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType e this.gameInstance = gameInstance; this.launchOptions = launchOptions; this.logs = logs; - this.analyzer = GameComponentAnalyzer.analyze(gameInstance.getResolvedManifest(), gameInstance.getVersion().toString()); + this.analyzer = gameInstance.getAnalyzer(); memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-"); 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 ea6124c7fc7..6a5378b03e7 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 @@ -30,6 +30,7 @@ import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.SettingsMap; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.util.Optional; @@ -82,7 +83,7 @@ private String getVersion(String id) { @Override protected void reload() { GameInstanceManifest.Resolved resolvedManifest = repository.resolve(manifest); - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedManifest, repository.getGameVersion(manifest).orElse(null)); + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedManifest, repository.getGameVersion(manifest).map(GameVersionNumber::asGameVersion).orElse(null)); String game = analyzer.getVersion(GameComponentType.GAME); String currentGameVersion = Lang.nonNull(getVersion("game"), game); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java index 1dba09c1b75..97613aa9ac7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java @@ -266,15 +266,13 @@ protected DownloadPageSkin(DownloadPage control) { FXUtils.onChangeAndOperate(control.loaded, loaded -> { if (control.versions == null) return; - if (control.instanceReference.repository() != null && control.instanceReference.instanceId() != null) { - HMCLGameRepository repository = control.instanceReference.repository(); - GameInstanceManifest.Resolved resolvedManifest = repository.getResolvedInstanceManifest(control.instanceReference.instanceId()); - String gameVersion = repository.getGameVersion(resolvedManifest.unresolved()).orElse(null); - - if (gameVersion != null && control.versions.containsKey(gameVersion)) { + @Nullable HMCLGameInstance instance = control.instanceReference.instance(); + if (instance != null) { + String gameVersion = instance.getVersion().toString(); + if (GameVersionNumber.unknown().equals(instance.getVersion()) && control.versions.containsKey(gameVersion)) { List modVersions = control.versions.get(gameVersion); if (modVersions != null && !modVersions.isEmpty()) { - Set targetLoaders = GameComponentAnalyzer.analyze(resolvedManifest, gameVersion).getModLoaders(); + Set targetLoaders = instance.getAnalyzer().getModLoaders(); resolve: for (RemoteAddon.Version modVersion : modVersions) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java index f65c476f4b1..9547350c204 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/GameItem.java @@ -104,7 +104,7 @@ record Result(@Nullable String gameVersion, @Nullable String tag) { } StringBuilder libraries = new StringBuilder(Objects.requireNonNullElse(result.gameVersion, i18n("message.unknown"))); - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(gameInstance.getResolvedManifest(), result.gameVersion); + GameComponentAnalyzer analyzer = gameInstance.getAnalyzer(); for (GameComponentAnalyzer.Mark mark : analyzer) { if (mark.componentType() == GameComponentType.GAME) continue; 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 a2a7a7f9ebd..6693d777b92 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 @@ -78,7 +78,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { HMCLGameRepository repository = gameInstance.getRepository(); - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(gameInstance.getResolvedManifest(), gameInstance.getVersion().toString()); + GameComponentAnalyzer analyzer = gameInstance.getAnalyzer(); itemsProperty().clear(); From cb3ddcf29483079b9afcb1ac2f92f3bb142c64a7 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 15:59:54 +0800 Subject: [PATCH 118/199] refactor(DefaultGameInstance): implement lazy initialization for GameComponentAnalyzer to optimize performance --- .../java/org/jackhuang/hmcl/game/DefaultGameInstance.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 29ff781495e..2ac31c9551c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -59,6 +59,8 @@ public abstract class DefaultGameInstance implements GameInstance { protected GameInstanceManifest.@Nullable Resolved resolvedManifest; + private @Nullable GameComponentAnalyzer analyzer; + /// Cached Minecraft game version detected from this instance's primary jar. /// /// `null` means detection has not been attempted yet. After detection, unknown results are @@ -161,7 +163,10 @@ public GameInstanceManifest.Resolved getResolvedManifest() { @Override public GameComponentAnalyzer getAnalyzer() { - return null; + if (analyzer == null) { + analyzer = GameComponentAnalyzer.analyze(getResolvedManifest(), getVersion()); + } + return analyzer; } /// {@inheritDoc} From a410c7947ad69c9b1dc7f8939bd44c31c289fe48 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 16:10:37 +0800 Subject: [PATCH 119/199] refactor(ModManager): replace direct GameComponentAnalyzer instantiation with instance method for improved modularity --- .../src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java index 1c955f6ab57..9afa6f1a890 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java @@ -182,7 +182,7 @@ public void refresh() throws IOException { localFiles.clear(); localMods.clear(); - analyzer = GameComponentAnalyzer.analyze(instance.getResolvedManifest(), null); + analyzer = instance.getAnalyzer(); boolean supportSubfolders = analyzer.has(GameComponentType.FORGE) || analyzer.has(GameComponentType.QUILT); From 28ecc5cd21f338030bfb31db2821530ef7c67ed2 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 16:28:21 +0800 Subject: [PATCH 120/199] refactor(AdditionalInstallersPage): refactor constructor to accept HMCLGameInstance and simplify GameComponentAnalyzer usage --- .../ui/download/AdditionalInstallersPage.java | 33 +++++++++---------- .../UpdateInstallerWizardProvider.java | 2 +- 2 files changed, 16 insertions(+), 19 deletions(-) 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 6a5378b03e7..90413d96e68 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 @@ -22,15 +22,11 @@ import javafx.beans.property.SimpleBooleanProperty; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.download.RemoteVersion; -import org.jackhuang.hmcl.game.GameComponentAnalyzer; -import org.jackhuang.hmcl.game.GameComponentType; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.HMCLGameRepository; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.ui.InstallerItem; import org.jackhuang.hmcl.ui.wizard.WizardController; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.SettingsMap; -import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import java.util.Optional; @@ -41,14 +37,16 @@ class AdditionalInstallersPage extends AbstractInstallersPage { protected final HMCLGameRepository repository; protected final String gameVersion; protected final GameInstanceManifest manifest; + protected final HMCLGameInstance instance; - public AdditionalInstallersPage(String gameVersion, GameInstanceManifest manifest, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) { + public AdditionalInstallersPage(HMCLGameInstance instance, String gameVersion, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) { super(controller, gameVersion, downloadProvider); + this.instance = instance; this.gameVersion = gameVersion; - this.manifest = manifest; + this.manifest = instance.getManifest(); this.repository = repository; - txtName.setText(manifest.id().toString()); + txtName.setText(instance.getId().toString()); txtName.setEditable(false); for (InstallerItem library : group.getLibraries()) { @@ -74,32 +72,31 @@ public String getTitle() { return i18n("settings.tabs.installers"); } - private String getVersion(String id) { - return Optional.ofNullable(controller.getSettings().get(id)) + private String getVersion(GameComponentType type) { + return Optional.ofNullable(controller.getSettings().get(type.getPatchId())) .flatMap(it -> Lang.tryCast(it, RemoteVersion.class)) .map(RemoteVersion::getSelfVersion).orElse(null); } @Override protected void reload() { - GameInstanceManifest.Resolved resolvedManifest = repository.resolve(manifest); - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedManifest, repository.getGameVersion(manifest).map(GameVersionNumber::asGameVersion).orElse(null)); + GameComponentAnalyzer analyzer = instance.getAnalyzer(); String game = analyzer.getVersion(GameComponentType.GAME); - String currentGameVersion = Lang.nonNull(getVersion("game"), game); + String currentGameVersion = Lang.nonNull(getVersion(GameComponentType.GAME), game); boolean compatible = true; for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getComponentType().getPatchId(); + GameComponentType componentType = library.getComponentType(); String version = analyzer.getVersion(library.getComponentType()); - String libraryVersion = Lang.requireNonNullElse(getVersion(libraryId), version); - boolean alreadyInstalled = version != null && !(controller.getSettings().get(libraryId) instanceof UpdateInstallerWizardProvider.RemoveVersionAction); - if (library.getComponentType() != GameComponentType.GAME && currentGameVersion != null && !currentGameVersion.equals(game) && getVersion(libraryId) == null && alreadyInstalled) { + String libraryVersion = Lang.requireNonNullElse(getVersion(componentType), version); + boolean alreadyInstalled = version != null && !(controller.getSettings().get(componentType.getPatchId()) instanceof UpdateInstallerWizardProvider.RemoveVersionAction); + if (library.getComponentType() != GameComponentType.GAME && currentGameVersion != null && !currentGameVersion.equals(game) && getVersion(componentType) == null && alreadyInstalled) { // For third-party libraries, if game version is being changed, and the library is not being reinstalled, // warns the user that we should update the library. library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, true)); compatible = false; - } else if (alreadyInstalled || getVersion(libraryId) != null) { + } else if (alreadyInstalled || getVersion(componentType) != null) { library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false)); } else { library.versionProperty().set(null); 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 cc81c7e1fba..f285df628b8 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 @@ -100,7 +100,7 @@ public Node createPage(WizardController controller, int step, SettingsMap settin controller.onFinish(); } else if ("game".equals(libraryId)) { String newGameVersion = ((RemoteVersion) settings.get(libraryId)).getSelfVersion(); - controller.onNext(new AdditionalInstallersPage(newGameVersion, gameInstance.getManifest(), controller, gameInstance.getRepository(), downloadProvider)); + controller.onNext(new AdditionalInstallersPage(gameInstance, newGameVersion, controller, gameInstance.getRepository(), downloadProvider)); } else { Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()), i18n("install.change_version"), controller::onFinish, controller::onCancel); From f582889d941c16e85224a19753e667675b534f39 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 16:29:14 +0800 Subject: [PATCH 121/199] refactor(AdditionalInstallersPage): remove repository parameter from constructor to simplify instantiation --- .../jackhuang/hmcl/ui/download/AdditionalInstallersPage.java | 4 +--- .../hmcl/ui/download/UpdateInstallerWizardProvider.java | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) 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 90413d96e68..0cb9b0e5c2f 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 @@ -34,17 +34,15 @@ class AdditionalInstallersPage extends AbstractInstallersPage { protected final BooleanProperty compatible = new SimpleBooleanProperty(); - protected final HMCLGameRepository repository; protected final String gameVersion; protected final GameInstanceManifest manifest; protected final HMCLGameInstance instance; - public AdditionalInstallersPage(HMCLGameInstance instance, String gameVersion, WizardController controller, HMCLGameRepository repository, DownloadProvider downloadProvider) { + public AdditionalInstallersPage(HMCLGameInstance instance, String gameVersion, WizardController controller, DownloadProvider downloadProvider) { super(controller, gameVersion, downloadProvider); this.instance = instance; this.gameVersion = gameVersion; this.manifest = instance.getManifest(); - this.repository = repository; txtName.setText(instance.getId().toString()); txtName.setEditable(false); 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 f285df628b8..e1c63f931dc 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 @@ -100,7 +100,7 @@ public Node createPage(WizardController controller, int step, SettingsMap settin controller.onFinish(); } else if ("game".equals(libraryId)) { String newGameVersion = ((RemoteVersion) settings.get(libraryId)).getSelfVersion(); - controller.onNext(new AdditionalInstallersPage(gameInstance, newGameVersion, controller, gameInstance.getRepository(), downloadProvider)); + controller.onNext(new AdditionalInstallersPage(gameInstance, newGameVersion, controller, downloadProvider)); } else { Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()), i18n("install.change_version"), controller::onFinish, controller::onCancel); From 17df4a1a89bd27d07495ac31dd96af0cbcbd53b3 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 16:29:47 +0800 Subject: [PATCH 122/199] refactor(AdditionalInstallersPage): update text name assignment to use id() method for clarity --- .../jackhuang/hmcl/ui/download/AdditionalInstallersPage.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0cb9b0e5c2f..470b191cc62 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 @@ -44,7 +44,7 @@ public AdditionalInstallersPage(HMCLGameInstance instance, String gameVersion, W this.gameVersion = gameVersion; this.manifest = instance.getManifest(); - txtName.setText(instance.getId().toString()); + txtName.setText(instance.getId().id()); txtName.setEditable(false); for (InstallerItem library : group.getLibraries()) { From d6b6701e4a3ca9e443bcbf86f50bcb3518f0b902 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 16:31:04 +0800 Subject: [PATCH 123/199] refactor(GameRepository): remove unused methods to streamline codebase --- .../org/jackhuang/hmcl/game/GameRepository.java | 17 ----------------- 1 file changed, 17 deletions(-) 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 04d8baab2fa..24c02638976 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -23,7 +23,6 @@ import java.nio.file.Files; import java.nio.file.Path; -import java.util.Collection; import java.util.LinkedHashSet; import java.util.Optional; import java.util.Set; @@ -85,15 +84,6 @@ default GameInstanceManifest getInstanceManifest(GameInstanceID instanceId) thro return getSnapshot().getInstance(instanceId).getManifest(); } - /// Returns a cached launch-ready manifest view for the instance. - /// - /// @param instanceId the instance id - /// @return the resolved manifest view - default GameInstanceManifest.Resolved getResolvedInstanceManifest(GameInstanceID instanceId) - throws NoSuchGameInstanceException { - return getSnapshot().getInstance(instanceId).getResolvedManifest(); - } - /// Returns the number of loaded instances. /// /// @return the loaded instance count @@ -101,13 +91,6 @@ default int getInstanceCount() { return getSnapshot().getInstanceCount(); } - /// Returns the stored manifests for all loaded instances. - /// - /// @return the loaded instance manifests - default Collection getInstanceManifests() { - return getSnapshot().getInstanceManifests(); - } - /// Returns the indexed game instance for the given id. /// /// @param id the instance id From 498b53b9c7437db179921818d53bae11bfde3ba3 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 16:48:37 +0800 Subject: [PATCH 124/199] refactor(DefaultGameRepositorySnapshot, LaunchManifestNormalizer): improve logging message and optimize library serialization --- .../org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java | 2 +- .../java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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 5c9151c2bf0..745b3880303 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -260,7 +260,7 @@ private GameInstanceManifest.Resolved resolveStructure( } else { // To maximize the compatibility. if (!resolvedSoFar.add(manifest.id())) { - LOG.warning("Found circular dependency versions: " + resolvedSoFar); + LOG.warning("Found circular dependency instances: " + resolvedSoFar); launchManifest = (manifest.jar() == null ? manifest.withJar(manifest.id()) : manifest) .withInheritsFrom(null); } else { 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 ae1ef8ad900..66eb3548576 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -255,7 +255,6 @@ private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifes for (Library library : manifest.getLibraries()) { String id = library.groupId() + ":" + library.artifactId(); VersionNumber version = VersionNumber.asVersion(library.version()); - String serialized = JsonUtils.GSON.toJson(library); if (!indexes.containsKey(id)) { indexes.put(id, libraries.size()); @@ -275,6 +274,7 @@ private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifes libraries.set(otherIndex, library); } else if (comparison == 0 && library.equals(other)) { String otherSerialized = JsonUtils.GSON.toJson(other); + String serialized = JsonUtils.GSON.toJson(library); if (serialized.length() > otherSerialized.length()) { libraries.set(otherIndex, library); } From b2c85301b6192d5a7b4bfb78f8c1585fa0f51b11 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 16:50:07 +0800 Subject: [PATCH 125/199] refactor(DefaultGameRepositorySnapshot, GameInstanceManifest): remove unused methods to clean up codebase --- .../jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java | 7 ------- .../java/org/jackhuang/hmcl/game/GameInstanceManifest.java | 7 ------- 2 files changed, 14 deletions(-) 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 745b3880303..a29847edd72 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -78,13 +78,6 @@ void seal() { } } - /// Returns whether this snapshot has been sealed. - /// - /// @return whether mutation is forbidden - public boolean isSealed() { - return sealed; - } - private void checkMutable() { if (sealed) { throw new IllegalStateException("Snapshot has been published and cannot be modified"); 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 4ff27fe4bd2..aecb38a4333 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java @@ -426,13 +426,6 @@ public AssetIndexInfo getAssetIndex() { } } - /// Returns whether this manifest applies to the current environment. - /// - /// @return whether this manifest applies to the current environment - public boolean appliesToCurrentEnvironment() { - return CompatibilityRule.appliesToCurrentEnvironment(compatibilityRules); - } - /// Resolves this manifest through the repository. /// /// @param repository the repository that provides parent manifests From 5feab4da0338f07bce6ab4f0ade924bf1e6b2f72 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 16:53:24 +0800 Subject: [PATCH 126/199] refactor(LaunchManifestNormalizer): improve code readability by restructuring conditional logic --- .../hmcl/game/LaunchManifestNormalizer.java | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) 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 66eb3548576..0b14c415bcc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -198,7 +198,7 @@ private static GameInstanceManifest normalizeBootstrapLauncher(GameInstanceManif String value = argument.toString(); if (value.startsWith("-DignoreList=") && !containsCommaSeparatedValue( - value.substring("-DignoreList=".length()), "${primary_jar_name}")) { + value.substring("-DignoreList=".length()), "${primary_jar_name}")) { jvmArguments.set(i, new StringArgument(value + ",${primary_jar_name}")); } } @@ -272,14 +272,16 @@ private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifes int comparison = version.compareTo(VersionNumber.asVersion(other.version())); if (comparison > 0) { libraries.set(otherIndex, library); - } else if (comparison == 0 && library.equals(other)) { - String otherSerialized = JsonUtils.GSON.toJson(other); - String serialized = JsonUtils.GSON.toJson(library); - if (serialized.length() > otherSerialized.length()) { - libraries.set(otherIndex, library); - } } else if (comparison == 0) { - continue; + if (library.equals(other)) { + String otherSerialized = JsonUtils.GSON.toJson(other); + String serialized = JsonUtils.GSON.toJson(library); + if (serialized.length() > otherSerialized.length()) { + libraries.set(otherIndex, library); + } + } else { + continue; + } } duplicate = true; break; From 48d8ad5be3377fa97b0e8b2b8ced948242e55961 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 19:31:48 +0800 Subject: [PATCH 127/199] Restore MaintainTask rationale comments on launch normalize/prepare paths Assisted-by: grok-build:grok-4.5 --- .../download/LaunchManifestPreparation.java | 15 +++++++ .../hmcl/game/LaunchManifestNormalizer.java | 44 +++++++++++++++---- .../hmcl/launch/LaunchClasspathResolver.java | 17 +++++-- 3 files changed, 64 insertions(+), 12 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java index d8078c85d7e..44251c19459 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java @@ -58,6 +58,12 @@ public static GameInstanceManifest prepare( /// Replaces unsafe substring-based ignore-list entries used by old BootstrapLauncher versions. /// + /// Fixes wrong configurations when launching 1.17+ with Forge / NeoForge under BootstrapLauncher + /// older than 0.1.17. Those versions apply each ignore-list token as a substring against every + /// classpath component. A game directory such as `/Users/asm` therefore causes every library + /// whose path contains `asm` to be ignored. The installed classpath is rewritten to exact paths + /// before launch. + /// /// @param repository the repository that resolves installed classpath entries /// @param manifest the normalized launch manifest /// @return the adjusted manifest @@ -99,6 +105,11 @@ private static GameInstanceManifest prepareBootstrapLauncher( /// Converts an old BootstrapLauncher ignore list to exact installed classpath entries. /// + /// The default ignore list is too loose for substring matching. For example, if `client-extra` + /// is listed and a path component contains `client-extra`, every matching library is ignored. + /// `${primary_jar}` is always included so the primary jar name cannot collide with Jigsaw module + /// naming conventions. + /// /// @param repository the repository that resolves installed classpath entries /// @param manifest the launch manifest /// @param ignoreList the original comma-separated substring list @@ -109,6 +120,8 @@ private static String updateIgnoreList( String ignoreList) { String[] ignoredSubstrings = ignoreList.split(","); List exactEntries = new ArrayList<>(); + // Primary jar must be ignored for Forge Jigsaw module discovery when its file name conflicts + // with module naming rules. exactEntries.add("${primary_jar}"); Path libraryDirectory = repository.getLayout().getLibrariesDirectory().toAbsolutePath().normalize(); @@ -116,8 +129,10 @@ private static String updateIgnoreList( Path classpathFile = Paths.get(classpathName).toAbsolutePath(); String fileName = classpathFile.getFileName().toString(); if (Stream.of(ignoredSubstrings).anyMatch(fileName::contains)) { + // Rewrite loose substrings to concrete paths so only the intended jars are ignored. String absolutePath; if (classpathFile.startsWith(libraryDirectory)) { + // Keep separators portable via placeholders (not the host File.separator alone). absolutePath = "${library_directory}${file_separator}" + libraryDirectory.relativize(classpathFile).toString() .replace(File.separator, "${file_separator}"); 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 0b14c415bcc..5f74d193378 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -55,21 +55,32 @@ public static GameInstanceManifest normalize(GameInstanceManifest manifest) { @Nullable String mainClass = normalized.mainClass(); if (GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(mainClass)) { + // LaunchWrapper era (Forge/LiteLoader/OptiFine on 1.12 and earlier, and mixed stacks). normalized = normalizeLaunchWrapper(normalized, true); if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(normalized.mainClass())) { + // OptiFine + ModLauncher may promote mainClass off LaunchWrapper. normalized = normalizeModLauncher(normalized); } } else if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(mainClass)) { + // Forge 1.13+ with OptiFine on ModLauncher. normalized = normalizeModLauncher(normalized); } else if (GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(mainClass)) { + // Forge / NeoForge 1.17+ BootstrapLauncher ignore-list form that does not need the + // installed filesystem (path-sensitive fixes run in LaunchManifestPreparation). normalized = normalizeBootstrapLauncher(normalized); } + // Vanilla and Fabric/Quilt need no loader-specific argument repair here; nothing currently + // coexists with Fabric the way OptiFine does with Forge/LiteLoader. return removeLegacyLog4jPatch(normalized); } /// Repairs LaunchWrapper tweak-class configuration. /// + /// Installing Forge can replace the full game argument list in the version JSON, which drops + /// LiteLoader and OptiFine tweakers. Compatible tweak classes are re-inserted in deterministic + /// order when still required. + /// /// @param manifest the resolved manifest /// @param reorderTweakClass whether retained tweak classes are moved to their required positions /// @return the repaired manifest @@ -80,8 +91,7 @@ private static GameInstanceManifest normalizeLaunchWrapper( GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); @Nullable String mainClass = null; - // Forge installers may replace the complete argument list, so compatible tweakers must be - // restored in deterministic order. + // Re-add LiteLoader tweaker when Forge overwrote the argument list (unless ModLauncher is in use). if (analyzer.has(GameComponentType.LITELOADER) && !analyzer.hasModLauncher()) { builder.replaceTweakClass( GameComponentAnalyzer.LITELOADER_TWEAKER, @@ -94,6 +104,7 @@ private static GameInstanceManifest normalizeLaunchWrapper( if (analyzer.has(GameComponentType.OPTIFINE)) { if (!analyzer.has(GameComponentType.LITELOADER) && !analyzer.has(GameComponentType.FORGE)) { + // Standalone OptiFine uses the plain OptiFine tweaker. if (builder.hasTweakClass(GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(1))) { builder.replaceTweakClass( GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(1), @@ -102,11 +113,13 @@ private static GameInstanceManifest normalizeLaunchWrapper( reorderTweakClass); } } else if (analyzer.hasModLauncher()) { + // Prefer ModLauncher over LaunchWrapper when both are present. mainClass = GameComponentAnalyzer.MOD_LAUNCHER_MAIN; for (String optiFineTweaker : GameComponentAnalyzer.OPTIFINE_TWEAKERS) { builder.removeTweakClass(optiFineTweaker); } } else if (builder.hasTweakClass(GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(0))) { + // With Forge or LiteLoader, OptiFine's Forge tweaker is required. builder.replaceTweakClass( GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(0), GameComponentAnalyzer.OPTIFINE_TWEAKERS.get(1), @@ -173,12 +186,15 @@ private static GameInstanceManifest normalizeModLauncher(GameInstanceManifest ma /// Repairs the filesystem-independent BootstrapLauncher ignore-list form. /// - /// BootstrapLauncher 0.1.17 and newer compare ignore-list entries only with file names, so the - /// primary jar placeholder can be added without inspecting the installed classpath. + /// BootstrapLauncher 0.1.17 and newer apply `ignoreList` only to the file name of each classpath + /// entry, so it is enough to ensure the primary jar name is listed. Older versions match + /// substrings against full paths and are repaired in `LaunchManifestPreparation` with the + /// installed classpath. /// /// @param manifest the resolved manifest /// @return the repaired manifest private static GameInstanceManifest normalizeBootstrapLauncher(GameInstanceManifest manifest) { + // Fix wrong configurations when launching 1.17+ with Forge / NeoForge. GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); if (!analyzer.has(GameComponentType.FORGE) && !analyzer.has(GameComponentType.NEO_FORGE)) { return manifest; @@ -190,6 +206,8 @@ private static GameInstanceManifest normalizeBootstrapLauncher(GameInstanceManif return manifest; } + // bootstraplauncher 0.1.17+ only applies ignoreList to classpath file names, so only the + // primary jar name needs to be fixed here. GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); List jvmArguments = builder.getMutableJvmArguments(); for (int i = 0; i < jvmArguments.size(); i++) { @@ -222,6 +240,9 @@ private static boolean containsCommaSeparatedValue(String values, String target) /// Removes the obsolete HMCL Log4j patch formerly prepended to affected manifests. /// + /// HMCL once injected `log4j-patch` to mitigate the Log4j vulnerability. The launcher now + /// rewrites `log4j2.xml` instead, so the leftover library entry is dropped. + /// /// @param manifest the normalized manifest /// @return the manifest without the obsolete first library, when present private static GameInstanceManifest removeLegacyLog4jPatch(GameInstanceManifest manifest) { @@ -242,8 +263,11 @@ private static GameInstanceManifest removeLegacyLog4jPatch(GameInstanceManifest /// Removes redundant library declarations while retaining rule-distinct variants. /// - /// For equal compatibility rules, the newer version wins. Identical coordinates retain the - /// declaration with the richer serialized metadata. + /// 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. /// /// @param manifest the resolved manifest /// @return the manifest with redundant libraries removed @@ -254,7 +278,6 @@ private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifes for (Library library : manifest.getLibraries()) { String id = library.groupId() + ":" + library.artifactId(); - VersionNumber version = VersionNumber.asVersion(library.version()); if (!indexes.containsKey(id)) { indexes.put(id, libraries.size()); @@ -265,21 +288,26 @@ private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifes boolean duplicate = false; for (int otherIndex : indexes.get(id)) { Library other = libraries.get(otherIndex); + // Rules differ: keep both (platform-specific variants). if (!CompatibilityRule.equals(library.rules(), other.rules())) { continue; } - int comparison = version.compareTo(VersionNumber.asVersion(other.version())); + // 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; } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java index 5daed09cb0d..0766c0bb38b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java @@ -38,10 +38,15 @@ private LaunchClasspathResolver() { /// Returns a mutable classpath containing installed libraries selected for this launch. /// - /// For Forge or LiteLoader installations containing OptiFine, an installed OptiFine installer - /// artifact replaces the ordinary artifact. With ModLauncher, the installer is omitted from the - /// ordinary classpath because transformer discovery loads it separately. The incompatible - /// `launchwrapper-of` artifact is also omitted. + /// When Forge or LiteLoader is present with OptiFine, the OptiFine *installer* jar is preferred + /// over the ordinary patch jar (the installer is what the Forge/LiteLoader tweaker stack expects). + /// OptiFine should load after Forge on the classpath: even when OptiFine is given higher install + /// priority, Forge may still appear without a patch entry, so the installer is re-appended at the + /// end. With ModLauncher the installer is omitted from this list because transformer discovery + /// loads it separately. + /// + /// OptiFine's custom `launchwrapper-of` artifact conflicts with the launchwrapper provided by + /// MinecraftForge, LiteLoader, or ModLoader and is therefore dropped. /// /// @param repository the repository that owns the installed libraries /// @param manifest the effective launch manifest @@ -55,12 +60,14 @@ public static Set resolve( return classpath; } + // With ModLauncher, OptiFine is discovered via HMCLTransformerDiscoveryService, not classpath. boolean removeFromClasspath = GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()); @Nullable Path selectedInstallerFile = null; for (Library library : manifest.getLibraries()) { Path libraryFile = repository.getLayout().getLibraryFile(manifest.id(), library); if (library.is("optifine", "OptiFine")) { + // Prefer the installer jar over the patch jar when both are present. Library installer = new Library( new Artifact("optifine", "OptiFine", library.version(), "installer")); Path installerFile = repository.getLayout().getLibraryFile(manifest.id(), installer); @@ -69,10 +76,12 @@ public static Set resolve( selectedInstallerFile = installerFile; } } else if (library.is("optifine", "launchwrapper-of")) { + // Drop OptiFine's private launchwrapper; Forge/LiteLoader supply their own. classpath.remove(FileUtils.getAbsolutePath(libraryFile)); } } + // Re-append the installer last so OptiFine follows Forge when Forge has no patch entry. if (!removeFromClasspath && selectedInstallerFile != null && Files.isRegularFile(selectedInstallerFile)) { From 8bcd5ea4981289adfa711648799810bb74b2caca Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 19:38:32 +0800 Subject: [PATCH 128/199] refactor(LaunchManifestNormalizer): simplify library update logic in manifest --- .../org/jackhuang/hmcl/game/LaunchManifestNormalizer.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 5f74d193378..07d3ec79f03 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -321,6 +321,8 @@ private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifes } } - return manifest.withLibraries(libraries); + return libraries.size() == manifest.getLibraries().size() + ? manifest + : manifest.withLibraries(libraries); } } From 6f56c5e0ee0a5fe1e02e81caa32c0aca9f258b46 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 19:47:13 +0800 Subject: [PATCH 129/199] Rewrite BootstrapLauncher ignoreList in DefaultLauncher and drop LaunchManifestPreparation Assisted-by: grok-build:grok-4.5 --- .../jackhuang/hmcl/game/LauncherHelper.java | 4 +- .../download/LaunchManifestPreparation.java | 148 ------------------ .../hmcl/game/GameComponentType.java | 4 +- .../hmcl/game/LaunchManifestNormalizer.java | 7 +- .../hmcl/launch/DefaultLauncher.java | 96 +++++++++++- .../hmcl/game/DefaultGameInstanceTest.java | 9 +- 6 files changed, 105 insertions(+), 163 deletions(-) delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java 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 6ff94f1cb8e..00b9b19dd4e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -25,7 +25,6 @@ import org.jackhuang.hmcl.auth.offline.OfflineAccount; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.download.LaunchManifestPreparation; import org.jackhuang.hmcl.download.game.*; import org.jackhuang.hmcl.java.JavaManager; import org.jackhuang.hmcl.java.JavaRuntime; @@ -158,8 +157,7 @@ private void launch0() { HMCLGameRepository repository = repository(); DefaultDependencyManager dependencyManager = repository.getDependency(); AtomicReference version = new AtomicReference<>( - LaunchManifestPreparation.prepare( - repository, gameInstance.getResolvedManifest().launchManifest())); + gameInstance.getResolvedManifest().launchManifest()); GameVersionNumber gameVersion = gameInstance.getVersion(); boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally(); CountDownLatch launchingLatch = new CountDownLatch(1); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java deleted file mode 100644 index 44251c19459..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/LaunchManifestPreparation.java +++ /dev/null @@ -1,148 +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.download; - -import org.jackhuang.hmcl.game.*; -import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.versioning.VersionNumber; -import org.jetbrains.annotations.NotNullByDefault; - -import java.io.File; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; -import java.util.stream.Stream; - -/// Applies launch-manifest argument adjustments that depend on the installed filesystem. -@NotNullByDefault -public final class LaunchManifestPreparation { - /// Prevents construction of this utility class. - private LaunchManifestPreparation() { - } - - /// Prepares a normalized launch manifest using the current library files. - /// - /// The input must not contain inheritance or pending patches. The returned manifest may replace - /// an old BootstrapLauncher ignore list but retains the input library list. - /// - /// @param repository the repository that owns the installed libraries - /// @param manifest the normalized launch manifest - /// @return the manifest to use for this launch attempt - /// @throws IllegalArgumentException if the manifest is not structurally resolved - public static GameInstanceManifest prepare( - GameRepository repository, - GameInstanceManifest manifest) { - if (manifest.inheritsFrom() != null || !manifest.getPatches().isEmpty()) { - throw new IllegalArgumentException("Launch manifest must be structurally resolved"); - } - - return prepareBootstrapLauncher(repository, manifest); - } - - /// Replaces unsafe substring-based ignore-list entries used by old BootstrapLauncher versions. - /// - /// Fixes wrong configurations when launching 1.17+ with Forge / NeoForge under BootstrapLauncher - /// older than 0.1.17. Those versions apply each ignore-list token as a substring against every - /// classpath component. A game directory such as `/Users/asm` therefore causes every library - /// whose path contains `asm` to be ignored. The installed classpath is rewritten to exact paths - /// before launch. - /// - /// @param repository the repository that resolves installed classpath entries - /// @param manifest the normalized launch manifest - /// @return the adjusted manifest - private static GameInstanceManifest prepareBootstrapLauncher( - GameRepository repository, - GameInstanceManifest manifest) { - if (!GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(manifest.mainClass())) { - return manifest; - } - - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); - if (!analyzer.has(GameComponentType.FORGE) && !analyzer.has(GameComponentType.NEO_FORGE)) { - return manifest; - } - - if (Optional.ofNullable(analyzer.getVersion(GameComponentType.BOOTSTRAP_LAUNCHER)) - .filter(version -> VersionNumber.compare(version, "0.1.17") < 0) - .isEmpty()) { - return manifest; - } - - GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); - List jvmArguments = builder.getMutableJvmArguments(); - for (int i = 0; i < jvmArguments.size(); i++) { - Argument argument = jvmArguments.get(i); - if (argument instanceof StringArgument) { - String value = argument.toString(); - if (value.startsWith("-DignoreList=")) { - jvmArguments.set(i, new StringArgument( - "-DignoreList=" + updateIgnoreList( - repository, - manifest, - value.substring("-DignoreList=".length())))); - } - } - } - return builder.build(); - } - - /// Converts an old BootstrapLauncher ignore list to exact installed classpath entries. - /// - /// The default ignore list is too loose for substring matching. For example, if `client-extra` - /// is listed and a path component contains `client-extra`, every matching library is ignored. - /// `${primary_jar}` is always included so the primary jar name cannot collide with Jigsaw module - /// naming conventions. - /// - /// @param repository the repository that resolves installed classpath entries - /// @param manifest the launch manifest - /// @param ignoreList the original comma-separated substring list - /// @return the exact comma-separated ignore list - private static String updateIgnoreList( - GameRepository repository, - GameInstanceManifest manifest, - String ignoreList) { - String[] ignoredSubstrings = ignoreList.split(","); - List exactEntries = new ArrayList<>(); - // Primary jar must be ignored for Forge Jigsaw module discovery when its file name conflicts - // with module naming rules. - exactEntries.add("${primary_jar}"); - - Path libraryDirectory = repository.getLayout().getLibrariesDirectory().toAbsolutePath().normalize(); - for (String classpathName : repository.getClasspath(manifest)) { - Path classpathFile = Paths.get(classpathName).toAbsolutePath(); - String fileName = classpathFile.getFileName().toString(); - if (Stream.of(ignoredSubstrings).anyMatch(fileName::contains)) { - // Rewrite loose substrings to concrete paths so only the intended jars are ignored. - String absolutePath; - if (classpathFile.startsWith(libraryDirectory)) { - // Keep separators portable via placeholders (not the host File.separator alone). - absolutePath = "${library_directory}${file_separator}" - + libraryDirectory.relativize(classpathFile).toString() - .replace(File.separator, "${file_separator}"); - } else { - absolutePath = classpathFile.toString(); - } - exactEntries.add(StringUtils.substringBefore(absolutePath, ",")); - } - } - return String.join(",", exactEntries); - } - -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java index 9abe965ad91..0d3a21e792a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -32,10 +32,12 @@ /// @author Glavo @NotNullByDefault public enum GameComponentType { + /// Minecraft itself is never identified from a library coordinate; the game version is supplied + /// separately to [GameComponentAnalyzer]. GAME("game") { @Override protected boolean matchLibrary(Library library, List libraries) { - return true; + return false; } }, LEGACY_FABRIC("legacyfabric", ModLoaderType.LEGACY_FABRIC) { 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 07d3ec79f03..44c89d29255 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -66,7 +66,8 @@ public static GameInstanceManifest normalize(GameInstanceManifest manifest) { normalized = normalizeModLauncher(normalized); } else if (GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(mainClass)) { // Forge / NeoForge 1.17+ BootstrapLauncher ignore-list form that does not need the - // installed filesystem (path-sensitive fixes run in LaunchManifestPreparation). + // installed filesystem (path-sensitive fixes for older BootstrapLauncher run in + // DefaultLauncher when building the process command). normalized = normalizeBootstrapLauncher(normalized); } // Vanilla and Fabric/Quilt need no loader-specific argument repair here; nothing currently @@ -188,8 +189,8 @@ private static GameInstanceManifest normalizeModLauncher(GameInstanceManifest ma /// /// BootstrapLauncher 0.1.17 and newer apply `ignoreList` only to the file name of each classpath /// entry, so it is enough to ensure the primary jar name is listed. Older versions match - /// substrings against full paths and are repaired in `LaunchManifestPreparation` with the - /// installed classpath. + /// substrings against full paths and are repaired in `DefaultLauncher` using the launch-time + /// library classpath. /// /// @param manifest the resolved manifest /// @return the repaired manifest 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 702ae2d54b8..d91e744be7d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.util.platform.*; import org.jackhuang.hmcl.util.platform.macos.HomebrewUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jetbrains.annotations.Nullable; import java.io.*; @@ -39,6 +40,7 @@ import java.nio.file.StandardCopyOption; import java.util.*; import java.util.function.Supplier; +import java.util.stream.Stream; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Pair.pair; @@ -273,15 +275,17 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { } } - Set classpath = LaunchClasspathResolver.resolve(instance.getRepository(), manifest); + // Library classpath used both for -cp and for rewriting old BootstrapLauncher ignore lists. + Set libraryClasspath = LaunchClasspathResolver.resolve(instance.getRepository(), manifest); if (analyzer.has(GameComponentType.CLEANROOM)) { - classpath.removeIf(c -> c.contains("2.9.4-nightly-20150209")); + libraryClasspath.removeIf(c -> c.contains("2.9.4-nightly-20150209")); } Path jar = instance.getRepository().getInstanceJar(manifest); if (!Files.isRegularFile(jar)) throw new IOException("Minecraft jar does not exist"); + Set classpath = new LinkedHashSet<>(libraryClasspath); classpath.add(FileUtils.getAbsolutePath(jar.toAbsolutePath())); // Provided Minecraft arguments @@ -307,6 +311,9 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { Path javaNativeFolder = FileUtils.toAbsolute(nativeFolder); @Nullable List jvmArguments = Optional.ofNullable(manifest.arguments()).map(Arguments::jvm).orElse(null); + if (jvmArguments != null) { + jvmArguments = rewriteUnsafeBootstrapLauncherIgnoreList(jvmArguments, libraryClasspath); + } if (jvmArguments != null) { for (Argument jvmArgument : jvmArguments) { @@ -524,6 +531,91 @@ public void extractLog4jConfigurationFile() throws IOException { } } + /// Rewrites `-DignoreList=` for old BootstrapLauncher when launching Forge / NeoForge. + /// + /// BootstrapLauncher older than 0.1.17 matches each ignore-list token as a substring against + /// every classpath component. A game directory such as `/Users/asm` therefore causes every + /// library whose path contains `asm` to be ignored. Using the library classpath already built + /// for this launch, loose tokens are replaced with exact installed paths (or portable + /// `${library_directory}` placeholders). `${primary_jar}` is always retained for Jigsaw. + /// + /// BootstrapLauncher 0.1.17+ only matches file names; those manifests are repaired at resolve + /// time by [LaunchManifestNormalizer]. + /// + /// @param jvmArguments JVM arguments from the launch manifest + /// @param libraryClasspath absolute library classpath entries for this launch (without primary jar) + /// @return a possibly rewritten argument list; the input list is not modified + private List rewriteUnsafeBootstrapLauncherIgnoreList( + List jvmArguments, + Set libraryClasspath) { + if (!GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(manifest.mainClass())) { + return jvmArguments; + } + if (!analyzer.has(GameComponentType.FORGE) && !analyzer.has(GameComponentType.NEO_FORGE)) { + return jvmArguments; + } + @Nullable String bootstrapVersion = analyzer.getVersion(GameComponentType.BOOTSTRAP_LAUNCHER); + if (bootstrapVersion == null || VersionNumber.compare(bootstrapVersion, "0.1.17") >= 0) { + return jvmArguments; + } + + Path libraryDirectory = instance.getLayout().getLibrariesDirectory().toAbsolutePath().normalize(); + List rewritten = new ArrayList<>(jvmArguments.size()); + boolean changed = false; + for (Argument argument : jvmArguments) { + if (argument instanceof StringArgument stringArgument) { + String value = stringArgument.argument(); + if (value.startsWith("-DignoreList=")) { + rewritten.add(new StringArgument( + "-DignoreList=" + rewriteIgnoreList( + value.substring("-DignoreList=".length()), + libraryClasspath, + libraryDirectory))); + changed = true; + continue; + } + } + rewritten.add(argument); + } + return changed ? rewritten : jvmArguments; + } + + /// Converts a substring-based BootstrapLauncher ignore list to exact classpath entries. + /// + /// For example, if `client-extra` is listed and a path component contains `client-extra`, + /// every matching library would be ignored under substring matching. Matching is performed + /// against library file names only; matched jars are rewritten to concrete paths. + /// + /// @param ignoreList the original comma-separated substring list + /// @param libraryClasspath absolute library classpath entries for this launch + /// @param libraryDirectory absolute `.minecraft/libraries` directory + /// @return the exact comma-separated ignore list + private static String rewriteIgnoreList( + String ignoreList, + Set libraryClasspath, + Path libraryDirectory) { + String[] ignoredSubstrings = ignoreList.split(","); + List exactEntries = new ArrayList<>(); + exactEntries.add("${primary_jar}"); + + for (String classpathName : libraryClasspath) { + Path classpathFile = Paths.get(classpathName).toAbsolutePath(); + String fileName = classpathFile.getFileName().toString(); + if (Stream.of(ignoredSubstrings).anyMatch(fileName::contains)) { + String absolutePath; + if (classpathFile.startsWith(libraryDirectory)) { + absolutePath = "${library_directory}${file_separator}" + + libraryDirectory.relativize(classpathFile).toString() + .replace(File.separator, "${file_separator}"); + } else { + absolutePath = classpathFile.toString(); + } + exactEntries.add(StringUtils.substringBefore(absolutePath, ",")); + } + } + return String.join(",", exactEntries); + } + protected Map getConfigurations() { return mapOf( // defined by Minecraft official launcher 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 1f8e29c55f4..da5da663e13 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -19,7 +19,6 @@ import org.jackhuang.hmcl.download.DefaultCacheRepository; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.download.LaunchManifestPreparation; import org.jackhuang.hmcl.download.MojangDownloadProvider; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameVerificationFixTask; @@ -136,17 +135,15 @@ public void testLaunchClasspathSelectsInstalledOptiFine(@TempDir Path tempDirect Files.write(optiFineLaunchWrapperFile, new byte[]{1}); Files.write(installerFile, new byte[]{1}); - GameInstanceManifest prepared = LaunchManifestPreparation.prepare(repository, launchManifest); - Set classpath = LaunchClasspathResolver.resolve(repository, prepared); + Set classpath = LaunchClasspathResolver.resolve(repository, launchManifest); - assertSame(launchManifest, prepared); assertEquals(Set.of( forgeFile.toAbsolutePath().toString(), installerFile.toAbsolutePath().toString()), classpath); - assertTrue(prepared.getLibraries().stream() + assertTrue(launchManifest.getLibraries().stream() .anyMatch(library -> library.is("optifine", "OptiFine") && library.classifier() == null)); - assertTrue(prepared.getLibraries().stream() + assertTrue(launchManifest.getLibraries().stream() .anyMatch(library -> library.is("optifine", "launchwrapper-of"))); } From a8348aba1539d6dcd18d2a8afe194080155f5ae2 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 19:55:16 +0800 Subject: [PATCH 130/199] refactor(LauncherHelper, NativePatcher): replace AtomicReference usage for launchManifest and improve code clarity --- .../jackhuang/hmcl/game/LauncherHelper.java | 21 +++++++++---------- .../jackhuang/hmcl/util/NativePatcher.java | 2 +- .../java/org/jackhuang/hmcl/game/Library.java | 2 ++ 3 files changed, 13 insertions(+), 12 deletions(-) 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 00b9b19dd4e..c394be6c346 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -156,8 +156,7 @@ private void launch0() { HMCLGameRepository repository = repository(); DefaultDependencyManager dependencyManager = repository.getDependency(); - AtomicReference version = new AtomicReference<>( - gameInstance.getResolvedManifest().launchManifest()); + var launchManifest = new AtomicReference<>(gameInstance.getResolvedManifest().launchManifest()); GameVersionNumber gameVersion = gameInstance.getVersion(); boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally(); CountDownLatch launchingLatch = new CountDownLatch(1); @@ -166,14 +165,14 @@ private void launch0() { AtomicReference javaVersionRef = new AtomicReference<>(); - TaskExecutor executor = checkGameState(gameInstance, setting, version.get()) + TaskExecutor executor = checkGameState(gameInstance, setting, launchManifest.get()) .thenComposeAsync(java -> { javaVersionRef.set(Objects.requireNonNull(java)); - version.set(NativePatcher.patchNative(gameInstance, version.get(), gameVersion, java, setting, javaArguments)); + launchManifest.set(NativePatcher.patchNative(gameInstance, launchManifest.get(), java, setting, javaArguments)); if (setting.getInheritable(GameSettings::notCheckGameProperty)) return null; return Task.allOf( - dependencyManager.checkGameCompletionAsync(gameInstance, version.get(), integrityCheck), + dependencyManager.checkGameCompletionAsync(gameInstance, launchManifest.get(), integrityCheck), Task.composeAsync(() -> { try { @Nullable ModpackConfiguration configuration = @@ -199,7 +198,7 @@ private void launch0() { if (lib == null) return null; GameRepository gameRepository = dependencyManager.getGameRepository(); - GameInstanceManifest manifest = version.get(); + GameInstanceManifest manifest = launchManifest.get(); Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), lib); if (file.toAbsolutePath().toString().indexOf('=') >= 0) { LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader"); @@ -208,7 +207,7 @@ private void launch0() { String agent = FileUtils.getAbsolutePath(file) + "=" + renderer.mesaDriverName(); - if (GameLibrariesTask.shouldDownloadLibrary(repository, version.get(), lib, integrityCheck)) { + if (GameLibrariesTask.shouldDownloadLibrary(repository, launchManifest.get(), lib, integrityCheck)) { return new LibraryDownloadTask(dependencyManager, file, lib) .thenRunAsync(() -> javaAgents.add(agent)); } else { @@ -219,14 +218,14 @@ private void launch0() { ); }).withStage("launch.state.dependencies") .thenComposeAsync(() -> { - return new GameVerificationFixTask(gameInstance, gameVersion, version.get()); + return new GameVerificationFixTask(gameInstance, gameVersion, launchManifest.get()); }) .thenComposeAsync(() -> { if (setting.getInheritable(GameSettings::allowAutoAgentProperty) || setting.getInheritable(GameSettings::noJVMOptionsProperty) || setting.getInheritable(GameSettings::noOptimizingJVMOptionsProperty) || Boolean.TRUE.equals(state().getShownTips().get(LWJGL_3_4_1_TIP)) - || !NativePatcher.needPatchMemoryUtil(version.get(), javaVersionRef.get().getParsedVersion())) { + || !NativePatcher.needPatchMemoryUtil(launchManifest.get(), javaVersionRef.get().getParsedVersion())) { return Task.completed(null); } else { CompletableFuture future = new CompletableFuture<>(); @@ -290,12 +289,12 @@ private void launch0() { return new HMCLGameLauncher( gameInstance, - version.get(), + launchManifest.get(), authInfo, launchOptions, launcherVisibility == LauncherVisibility.CLOSE ? null // Unnecessary to start listening to game process output when close launcher immediately after game launched. - : new HMCLProcessListener(repository, version.get(), authInfo, launchOptions, launchingLatch, gameVersion.compareTo(GameVersionNumber.unknown()) != 0) + : new HMCLProcessListener(repository, launchManifest.get(), authInfo, launchOptions, launchingLatch, gameVersion.compareTo(GameVersionNumber.unknown()) != 0) ); }).thenComposeAsync(launcher -> { // launcher is prev task's result if (scriptFile == null) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java index b248cc7fe62..32c1deb5d3e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java @@ -75,10 +75,10 @@ public static boolean needPatchMemoryUtil(GameInstanceManifest manifest, int jav public static GameInstanceManifest patchNative(DefaultGameInstance instance, GameInstanceManifest manifest, - @NotNull GameVersionNumber gameVersion, JavaRuntime javaVersion, GameSettings.Effective settings, List javaArguments) { + GameVersionNumber gameVersion = instance.getVersion(); if (settings.getInheritable(GameSettings::useCustomNativesProperty)) { if (gameVersion.compareTo("1.19") < 0) return manifest; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Library.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Library.java index 5d709d9ca0c..e47bc158a2f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Library.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/Library.java @@ -24,6 +24,7 @@ import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.platform.Architecture; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -151,6 +152,7 @@ public String version() { return artifact.getVersion(); } + @Contract(pure = true) public @Nullable String classifier() { if (artifact.getClassifier() == null) { if (natives != null) { From 91acd3070cfe4a7108a255cf917c219888eef1eb Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 19:57:03 +0800 Subject: [PATCH 131/199] refactor(LauncherHelper): streamline asynchronous task composition for game verification --- .../src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) 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 c394be6c346..cfb156c64dc 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -217,9 +217,7 @@ private void launch0() { }) ); }).withStage("launch.state.dependencies") - .thenComposeAsync(() -> { - return new GameVerificationFixTask(gameInstance, gameVersion, launchManifest.get()); - }) + .thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameVersion, launchManifest.get())) .thenComposeAsync(() -> { if (setting.getInheritable(GameSettings::allowAutoAgentProperty) || setting.getInheritable(GameSettings::noJVMOptionsProperty) From f85bc48b8f5efcd80141bf90af0407abeb063595 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 19:59:45 +0800 Subject: [PATCH 132/199] refactor(LauncherHelper): replace gameVersion variable with direct method call for clarity --- .../org/jackhuang/hmcl/game/LauncherHelper.java | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) 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 cfb156c64dc..05ac71d4717 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -157,7 +157,6 @@ private void launch0() { HMCLGameRepository repository = repository(); DefaultDependencyManager dependencyManager = repository.getDependency(); var launchManifest = new AtomicReference<>(gameInstance.getResolvedManifest().launchManifest()); - GameVersionNumber gameVersion = gameInstance.getVersion(); boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally(); CountDownLatch launchingLatch = new CountDownLatch(1); List javaAgents = new ArrayList<>(0); @@ -190,7 +189,7 @@ private void launch0() { }), Task.composeAsync(() -> { if (OperatingSystem.CURRENT_OS != OperatingSystem.WINDOWS - || !(setting.getRenderer(gameVersion) instanceof Renderer.Driver renderer) + || !(setting.getRenderer(gameInstance.getVersion()) instanceof Renderer.Driver renderer) || renderer.mesaDriverName() == null) return null; @@ -217,7 +216,7 @@ private void launch0() { }) ); }).withStage("launch.state.dependencies") - .thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameVersion, launchManifest.get())) + .thenComposeAsync(() -> new GameVerificationFixTask(gameInstance, gameInstance.getVersion(), launchManifest.get())) .thenComposeAsync(() -> { if (setting.getInheritable(GameSettings::allowAutoAgentProperty) || setting.getInheritable(GameSettings::noJVMOptionsProperty) @@ -292,7 +291,7 @@ private void launch0() { launchOptions, launcherVisibility == LauncherVisibility.CLOSE ? null // Unnecessary to start listening to game process output when close launcher immediately after game launched. - : new HMCLProcessListener(repository, launchManifest.get(), authInfo, launchOptions, launchingLatch, gameVersion.compareTo(GameVersionNumber.unknown()) != 0) + : new HMCLProcessListener(repository, launchManifest.get(), authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0) ); }).thenComposeAsync(launcher -> { // launcher is prev task's result if (scriptFile == null) { @@ -356,8 +355,7 @@ public void onStop(boolean success, TaskExecutor executor) { message = i18n("launch.failed.decompressing_natives") + "\n" + ex.getLocalizedMessage(); } else if (ex instanceof LibraryDownloadException) { message = i18n("launch.failed.download_library", ((LibraryDownloadException) ex).getLibrary().name()) + "\n"; - if (ex.getCause() instanceof ResponseCodeException) { - ResponseCodeException rce = (ResponseCodeException) ex.getCause(); + if (ex.getCause() instanceof ResponseCodeException rce) { int responseCode = rce.getResponseCode(); String uri = rce.getUri(); if (responseCode == 404) @@ -371,8 +369,7 @@ public void onStop(boolean success, TaskExecutor executor) { URI uri = ((DownloadException) ex).getUri(); if (ex.getCause() instanceof SocketTimeoutException) { message = i18n("install.failed.downloading.timeout", uri); - } else if (ex.getCause() instanceof ResponseCodeException) { - ResponseCodeException responseCodeException = (ResponseCodeException) ex.getCause(); + } else if (ex.getCause() instanceof ResponseCodeException responseCodeException) { if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) { message = i18n("download.code." + responseCodeException.getResponseCode(), uri); } else { @@ -387,8 +384,7 @@ public void onStop(boolean success, TaskExecutor executor) { message = i18n("account.failed.injector_download_failure"); } else if (ex instanceof CharacterDeletedException) { message = i18n("account.failed.character_deleted"); - } else if (ex instanceof ResponseCodeException) { - ResponseCodeException rce = (ResponseCodeException) ex; + } else if (ex instanceof ResponseCodeException rce) { int responseCode = rce.getResponseCode(); String uri = rce.getUri(); if (responseCode == 404) From cd318140ad02532829a46d17d43ee5718424312a Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 20:01:12 +0800 Subject: [PATCH 133/199] refactor(LauncherHelper): simplify HMCLProcessListener constructor by removing unnecessary repository and manifest parameters --- .../org/jackhuang/hmcl/game/LauncherHelper.java | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) 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 05ac71d4717..e256f4aa3a7 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -291,7 +291,7 @@ private void launch0() { launchOptions, launcherVisibility == LauncherVisibility.CLOSE ? null // Unnecessary to start listening to game process output when close launcher immediately after game launched. - : new HMCLProcessListener(repository, launchManifest.get(), authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0) + : new HMCLProcessListener(authInfo, launchOptions, launchingLatch, gameInstance.getVersion().compareTo(GameVersionNumber.unknown()) != 0) ); }).thenComposeAsync(launcher -> { // launcher is prev task's result if (scriptFile == null) { @@ -832,16 +832,12 @@ private void enableAutoAgentForCurrentSetting() { } } - /** - * The managed process listener. - * Guarantee that one [JavaProcess], one [HMCLProcessListener]. - * Because every time we launched a game, we generates a new [HMCLProcessListener] - */ + /// The managed process listener. + /// Guarantee that one Java [Process], one [HMCLProcessListener]. + /// Because every time we launched a game, we generates a new [HMCLProcessListener] private final class HMCLProcessListener implements ProcessListener { private final ReentrantLock lock = new ReentrantLock(); - private final HMCLGameRepository repository; - private final GameInstanceManifest manifest; private final LaunchOptions launchOptions; private ManagedProcess process; private volatile boolean lwjgl; @@ -853,9 +849,7 @@ private final class HMCLProcessListener implements ProcessListener { private Thread submitLogThread; private LinkedBlockingQueue logBuffer; - public HMCLProcessListener(HMCLGameRepository repository, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) { - this.repository = repository; - this.manifest = manifest; + public HMCLProcessListener(AuthInfo authInfo, LaunchOptions launchOptions, CountDownLatch launchingLatch, boolean detectWindow) { this.launchOptions = launchOptions; this.launchingLatch = launchingLatch; this.detectWindow = detectWindow; From f925f6006447d82be7e84b3f7d04aacf43a44c8f Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 20:05:45 +0800 Subject: [PATCH 134/199] refactor(DefaultLauncher, GameInstance): replace GameComponentAnalyzer usage with instance method for clarity --- .../org/jackhuang/hmcl/game/GameInstance.java | 4 +++ .../hmcl/launch/DefaultLauncher.java | 31 +++++++------------ 2 files changed, 16 insertions(+), 19 deletions(-) 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 28f0a63fdd0..2c29b0c49e5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -63,6 +63,10 @@ default GameInstanceManifest getLaunchManifest() { GameComponentAnalyzer getAnalyzer(); + default boolean hasComponent(GameComponentType type) { + return getAnalyzer().has(type); + } + GameVersionNumber getVersion(); /// Returns the directory containing files owned by this instance. 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 d91e744be7d..7beb0aacb61 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -46,18 +46,11 @@ import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/** - * @author huangyuhui - */ +/// @author huangyuhui public class DefaultLauncher extends Launcher { - private final GameComponentAnalyzer analyzer; - public DefaultLauncher(GameInstance instance, GameInstanceManifest manifest, AuthInfo authInfo, LaunchOptions options, ProcessListener listener, boolean daemon) { super(instance, manifest, authInfo, options, listener, daemon); - - GameVersionNumber version = instance.getVersion(); - this.analyzer = GameComponentAnalyzer.analyze(manifest, version); } private Command generateCommandLine(Path nativeFolder) throws IOException { @@ -278,7 +271,7 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { // Library classpath used both for -cp and for rewriting old BootstrapLauncher ignore lists. Set libraryClasspath = LaunchClasspathResolver.resolve(instance.getRepository(), manifest); - if (analyzer.has(GameComponentType.CLEANROOM)) { + if (instance.hasComponent(GameComponentType.CLEANROOM)) { libraryClasspath.removeIf(c -> c.contains("2.9.4-nightly-20150209")); } @@ -551,10 +544,10 @@ private List rewriteUnsafeBootstrapLauncherIgnoreList( if (!GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(manifest.mainClass())) { return jvmArguments; } - if (!analyzer.has(GameComponentType.FORGE) && !analyzer.has(GameComponentType.NEO_FORGE)) { + if (!instance.hasComponent(GameComponentType.FORGE) && !instance.hasComponent(GameComponentType.NEO_FORGE)) { return jvmArguments; } - @Nullable String bootstrapVersion = analyzer.getVersion(GameComponentType.BOOTSTRAP_LAUNCHER); + @Nullable String bootstrapVersion = instance.getAnalyzer().getVersion(GameComponentType.BOOTSTRAP_LAUNCHER); if (bootstrapVersion == null || VersionNumber.compare(bootstrapVersion, "0.1.17") >= 0) { return jvmArguments; } @@ -776,28 +769,28 @@ else if (driver instanceof Renderer.Vulkan vulkanDriver) { } } - if (analyzer.has(GameComponentType.FORGE)) { + if (instance.hasComponent(GameComponentType.FORGE)) { env.put("INST_FORGE", "1"); } - if (analyzer.has(GameComponentType.CLEANROOM)) { + if (instance.hasComponent(GameComponentType.CLEANROOM)) { env.put("INST_CLEANROOM", "1"); } - if (analyzer.has(GameComponentType.NEO_FORGE)) { + if (instance.hasComponent(GameComponentType.NEO_FORGE)) { env.put("INST_NEOFORGE", "1"); } - if (analyzer.has(GameComponentType.LITELOADER)) { + if (instance.hasComponent(GameComponentType.LITELOADER)) { env.put("INST_LITELOADER", "1"); } - if (analyzer.has(GameComponentType.FABRIC)) { + if (instance.hasComponent(GameComponentType.FABRIC)) { env.put("INST_FABRIC", "1"); } - if (analyzer.has(GameComponentType.OPTIFINE)) { + if (instance.hasComponent(GameComponentType.OPTIFINE)) { env.put("INST_OPTIFINE", "1"); } - if (analyzer.has(GameComponentType.QUILT)) { + if (instance.hasComponent(GameComponentType.QUILT)) { env.put("INST_QUILT", "1"); } - if (analyzer.has(GameComponentType.LEGACY_FABRIC)) { + if (instance.hasComponent(GameComponentType.LEGACY_FABRIC)) { env.put("INST_LEGACYFABRIC", "1"); } From ef0e0ee45ce6651195e75e619ee03e674ee6aa92 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 20:12:19 +0800 Subject: [PATCH 135/199] refactor(AdditionalInstallersPage, GameCrashWindow, GameInstance): replace GameComponentAnalyzer usage with direct method calls for clarity --- .../main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java | 4 +--- .../hmcl/ui/download/AdditionalInstallersPage.java | 9 +++------ .../main/java/org/jackhuang/hmcl/game/GameInstance.java | 5 +++++ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index d21fc711328..48bb34cf228 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -76,7 +76,6 @@ public class GameCrashWindow extends Stage { private final String memory; private final String total_memory; private final String java; - private final GameComponentAnalyzer analyzer; private final TextFlow reasonTextFlow = new TextFlow(new Text(i18n("game.crash.reason.unknown"))); private final BooleanProperty loading = new SimpleBooleanProperty(); private final TextFlow feedbackTextFlow = new TextFlow(); @@ -97,7 +96,6 @@ public GameCrashWindow(ManagedProcess managedProcess, ProcessListener.ExitType e this.gameInstance = gameInstance; this.launchOptions = launchOptions; this.logs = logs; - this.analyzer = gameInstance.getAnalyzer(); memory = Optional.ofNullable(launchOptions.getMaxMemory()).map(i -> i + " " + i18n("settings.memory.unit.mib")).orElse("-"); @@ -378,7 +376,7 @@ private final class View extends VBox { moddedPane.setPadding(new Insets(8)); moddedPane.setAlignment(Pos.CENTER_LEFT); - for (GameComponentAnalyzer.Mark mark : analyzer) { + for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) { if (mark.version() != null) { TwoLineListItem item = new TwoLineListItem(); item.getStyleClass().setAll("two-line-item-second-large"); 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 470b191cc62..de954cde344 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 @@ -78,18 +78,15 @@ private String getVersion(GameComponentType type) { @Override protected void reload() { - GameComponentAnalyzer analyzer = instance.getAnalyzer(); - String game = analyzer.getVersion(GameComponentType.GAME); - String currentGameVersion = Lang.nonNull(getVersion(GameComponentType.GAME), game); - + boolean gameVersionChanged = !instance.getVersion().toString().equals(getVersion(GameComponentType.GAME)); boolean compatible = true; for (InstallerItem library : group.getLibraries()) { GameComponentType componentType = library.getComponentType(); - String version = analyzer.getVersion(library.getComponentType()); + String version = instance.getComponentVersion(library.getComponentType()); String libraryVersion = Lang.requireNonNullElse(getVersion(componentType), version); boolean alreadyInstalled = version != null && !(controller.getSettings().get(componentType.getPatchId()) instanceof UpdateInstallerWizardProvider.RemoveVersionAction); - if (library.getComponentType() != GameComponentType.GAME && currentGameVersion != null && !currentGameVersion.equals(game) && getVersion(componentType) == null && alreadyInstalled) { + if (library.getComponentType() != GameComponentType.GAME && gameVersionChanged && getVersion(componentType) == null && alreadyInstalled) { // For third-party libraries, if game version is being changed, and the library is not being reinstalled, // warns the user that we should update the library. library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, true)); 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 2c29b0c49e5..45e44ff8589 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Path; @@ -67,6 +68,10 @@ default boolean hasComponent(GameComponentType type) { return getAnalyzer().has(type); } + default @Nullable String getComponentVersion(GameComponentType type) { + return getAnalyzer().getVersion(type); + } + GameVersionNumber getVersion(); /// Returns the directory containing files owned by this instance. From 57320a6afb7250127de5f4c06316c2e94f827c9a Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 20:16:09 +0800 Subject: [PATCH 136/199] refactor(DownloadPage, GameInstance, ModManager): replace GameComponentAnalyzer usage with direct method calls for clarity --- .../jackhuang/hmcl/ui/instances/DownloadPage.java | 2 +- .../org/jackhuang/hmcl/addon/mod/ModManager.java | 2 +- .../jackhuang/hmcl/game/GameComponentAnalyzer.java | 10 ---------- .../java/org/jackhuang/hmcl/game/GameInstance.java | 13 +++++++++++++ .../hmcl/modpack/mcbbs/McbbsModpackExportTask.java | 9 ++++++--- .../modpack/server/ServerModpackExportTask.java | 3 +-- 6 files changed, 22 insertions(+), 17 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java index 97613aa9ac7..ba932c77ada 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/DownloadPage.java @@ -272,7 +272,7 @@ protected DownloadPageSkin(DownloadPage control) { if (GameVersionNumber.unknown().equals(instance.getVersion()) && control.versions.containsKey(gameVersion)) { List modVersions = control.versions.get(gameVersion); if (modVersions != null && !modVersions.isEmpty()) { - Set targetLoaders = instance.getAnalyzer().getModLoaders(); + Set targetLoaders = instance.getModLoaders(); resolve: for (RemoteAddon.Version modVersion : modVersions) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java index 9afa6f1a890..7504d07ec05 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/ModManager.java @@ -114,7 +114,7 @@ private void addModInfo(Path file) { return; } - Set modLoaderTypes = analyzer.getModLoaders(); + Set modLoaderTypes = instance.getModLoaders(); var supportedReaders = new ArrayList(); var unsupportedReaders = new ArrayList(); 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 1daced7ba0d..a45f8b24775 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -153,16 +153,6 @@ public boolean isClear(GameComponentType type) { return manifest.hasPatch(type.getPatchId()); } - public @Unmodifiable Set getModLoaders() { - Set res = EnumSet.noneOf(ModLoaderType.class); - for (GameComponentType type : components.keySet()) { - if (type.getModLoaderType() != null) { - res.add(type.getModLoaderType()); - } - } - return res; - } - @Override public Iterator iterator() { return components.values().iterator(); 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 45e44ff8589..85258c4b1f9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstance.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.game; +import org.jackhuang.hmcl.addon.mod.ModLoaderType; import org.jackhuang.hmcl.util.platform.Platform; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; @@ -24,7 +25,9 @@ import java.io.IOException; import java.nio.file.Path; +import java.util.EnumSet; import java.util.Optional; +import java.util.Set; /// Provides a view of a game instance and its instance-specific paths within a /// [GameRepositorySnapshot]. @@ -72,6 +75,16 @@ default boolean hasComponent(GameComponentType type) { return getAnalyzer().getVersion(type); } + default Set getModLoaders() { + Set res = EnumSet.noneOf(ModLoaderType.class); + for (GameComponentAnalyzer.Mark mark : getAnalyzer()) { + if (mark.componentType().getModLoaderType() != null) { + res.add(mark.componentType().getModLoaderType()); + } + } + return res; + } + GameVersionNumber getVersion(); /// Returns the directory containing files owned by this instance. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java index a6e5daead65..43b42500b22 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackExportTask.java @@ -134,9 +134,12 @@ public void execute() throws Exception { // CurseForge manifest List modLoaders = new ArrayList<>(); - Optional.ofNullable(analyzer.getVersion(FORGE)).ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("forge-" + forgeVersion, true))); - Optional.ofNullable(analyzer.getVersion(NEO_FORGE)).ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("neoforge-" + forgeVersion, true))); - Optional.ofNullable(analyzer.getVersion(FABRIC)).ifPresent(fabricVersion -> modLoaders.add(new CurseManifestModLoader("fabric-" + fabricVersion, true))); + Optional.ofNullable(analyzer.getVersion(FORGE)) + .ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("forge-" + forgeVersion, true))); + Optional.ofNullable(analyzer.getVersion(NEO_FORGE)) + .ifPresent(forgeVersion -> modLoaders.add(new CurseManifestModLoader("neoforge-" + forgeVersion, true))); + Optional.ofNullable(analyzer.getVersion(FABRIC)) + .ifPresent(fabricVersion -> modLoaders.add(new CurseManifestModLoader("fabric-" + fabricVersion, true))); // OptiFine and LiteLoader are not supported by CurseForge modpack. CurseManifest curseManifest = new CurseManifest(CurseManifest.MINECRAFT_MODPACK, 1, info.getName(), info.getVersion(), info.getAuthor(), "overrides", new CurseManifestMinecraft(gameVersion, modLoaders), Collections.emptyList()); zip.putTextFile(JsonUtils.GSON.toJson(curseManifest), "manifest.json"); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java index 03ba4de9f25..cd8b52cc8fa 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackExportTask.java @@ -103,11 +103,10 @@ public void execute() throws Exception { throw new IOException("Cannot parse the version of " + instanceId); } String gameVersion = version.toString(); - GameComponentAnalyzer analyzer = instance.getAnalyzer(); List addons = new ArrayList<>(); addons.add(new ServerModpackManifest.Addon(GameComponentType.GAME.getPatchId(), gameVersion)); - for (GameComponentAnalyzer.Mark mark : analyzer) { + for (GameComponentAnalyzer.Mark mark : instance.getAnalyzer()) { if ((mark.componentType().isModLoader() || mark.componentType() == GameComponentType.OPTIFINE) && mark.version() != null) { addons.add(new ServerModpackManifest.Addon(mark.componentType().getPatchId(), mark.version())); From 4336f434b85e937f9273f69037d947e83513a9af Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 20:18:28 +0800 Subject: [PATCH 137/199] refactor(AbstractInstallersPage, AdditionalInstallersPage, InstallerItem, InstallerListPage, InstallersPage): replace library references with component for consistency --- .../org/jackhuang/hmcl/ui/InstallerItem.java | 14 +++++------ .../ui/download/AbstractInstallersPage.java | 16 ++++++------- .../ui/download/AdditionalInstallersPage.java | 24 +++++++++---------- .../hmcl/ui/download/InstallersPage.java | 16 ++++++------- .../hmcl/ui/instances/InstallerListPage.java | 20 ++++++++-------- 5 files changed, 45 insertions(+), 45 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java index cb4e2be088b..18fd42bcf4d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/InstallerItem.java @@ -129,7 +129,7 @@ protected Skin createDefaultSkin() { public final static class InstallerItemGroup { private final InstallerItem game; - private final InstallerItem[] libraries; + private final InstallerItem[] components; private Set getIncompatibles(Map> incompatibleMap, InstallerItem item) { return incompatibleMap.computeIfAbsent(item, it -> new HashSet<>()); @@ -221,13 +221,13 @@ public InstallerItemGroup(GameVersionNumber gameVersion, Style style) { } if (gameVersion == null) { - this.libraries = all; + this.components = all; } else if (gameVersion.compareTo("1.12.2") == 0) { - this.libraries = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine}; + this.components = new InstallerItem[]{game, forge, cleanroom, liteLoader, legacyfabric, legacyfabricApi, optiFine}; } else if (gameVersion.compareTo("1.13.2") <= 0) { - this.libraries = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi}; + this.components = new InstallerItem[]{game, forge, liteLoader, optiFine, legacyfabric, legacyfabricApi}; } else { - this.libraries = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi}; + this.components = new InstallerItem[]{game, forge, neoForge, optiFine, fabric, fabricApi, quilt, quiltApi}; } } @@ -235,8 +235,8 @@ public InstallerItem getGame() { return game; } - public InstallerItem[] getLibraries() { - return libraries; + public InstallerItem[] getComponents() { + return components; } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java index 8003520fe78..cff5780a286 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java @@ -61,10 +61,10 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D this.controller = controller; this.group = new InstallerItem.InstallerItemGroup(GameVersionNumber.asGameVersion(gameVersion), getInstallerItemStyle()); - for (InstallerItem library : group.getLibraries()) { - GameComponentType type = library.getComponentType(); + for (InstallerItem component : group.getComponents()) { + GameComponentType type = component.getComponentType(); if (type == GameComponentType.GAME) continue; - library.setOnInstall(() -> { + component.setOnInstall(() -> { if (!Boolean.TRUE.equals(state().getShownTips().get(FABRIC_QUILT_API_TIP)) && (type == GameComponentType.FABRIC_API || type == GameComponentType.QUILT_API @@ -76,7 +76,7 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D ).ok(null).addCancel(i18n("button.do_not_show_again"), () -> state().getShownTips().put(FABRIC_QUILT_API_TIP, true)).build()); } - if (!(library.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState)) + if (!(component.resolvedStateProperty().get() instanceof InstallerItem.IncompatibleState)) controller.onNext( new VersionsPage( controller, @@ -88,7 +88,7 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D ), Navigation.NavigationDirection.NEXT ); }); - library.setOnRemove(() -> { + component.setOnRemove(() -> { controller.getSettings().remove(type.getPatchId()); reload(); }); @@ -167,16 +167,16 @@ protected InstallersPageSkin(AbstractInstallersPage control) { } { - InstallerItem[] libraries = control.group.getLibraries(); + InstallerItem[] components = control.group.getComponents(); - FlowPane libraryPane = new FlowPane(16, 16, libraries); + FlowPane libraryPane = new FlowPane(16, 16, components); ScrollPane scrollPane = new ScrollPane(libraryPane); scrollPane.setFitToWidth(true); scrollPane.setFitToHeight(true); BorderPane.setMargin(scrollPane, new Insets(16, 0, 16, 0)); root.setCenter(scrollPane); - if (libraries.length <= 8) + if (components.length <= 8) scrollPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java index de954cde344..73a7d645369 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AdditionalInstallersPage.java @@ -47,12 +47,12 @@ public AdditionalInstallersPage(HMCLGameInstance instance, String gameVersion, W txtName.setText(instance.getId().id()); txtName.setEditable(false); - for (InstallerItem library : group.getLibraries()) { - if (library.getComponentType() == GameComponentType.GAME) continue; - library.setOnRemove(() -> { + for (InstallerItem component : group.getComponents()) { + if (component.getComponentType() == GameComponentType.GAME) continue; + component.setOnRemove(() -> { controller.getSettings().put( - library.getComponentType().getPatchId(), - new UpdateInstallerWizardProvider.RemoveVersionAction(library.getComponentType())); + component.getComponentType().getPatchId(), + new UpdateInstallerWizardProvider.RemoveVersionAction(component.getComponentType())); reload(); }); } @@ -81,20 +81,20 @@ protected void reload() { boolean gameVersionChanged = !instance.getVersion().toString().equals(getVersion(GameComponentType.GAME)); boolean compatible = true; - for (InstallerItem library : group.getLibraries()) { - GameComponentType componentType = library.getComponentType(); - String version = instance.getComponentVersion(library.getComponentType()); + for (InstallerItem component : group.getComponents()) { + GameComponentType componentType = component.getComponentType(); + String version = instance.getComponentVersion(component.getComponentType()); String libraryVersion = Lang.requireNonNullElse(getVersion(componentType), version); boolean alreadyInstalled = version != null && !(controller.getSettings().get(componentType.getPatchId()) instanceof UpdateInstallerWizardProvider.RemoveVersionAction); - if (library.getComponentType() != GameComponentType.GAME && gameVersionChanged && getVersion(componentType) == null && alreadyInstalled) { + if (component.getComponentType() != GameComponentType.GAME && gameVersionChanged && getVersion(componentType) == null && alreadyInstalled) { // For third-party libraries, if game version is being changed, and the library is not being reinstalled, // warns the user that we should update the library. - library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, true)); + component.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, true)); compatible = false; } else if (alreadyInstalled || getVersion(componentType) != null) { - library.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false)); + component.versionProperty().set(new InstallerItem.InstalledState(libraryVersion, false, false)); } else { - library.versionProperty().set(null); + component.versionProperty().set(null); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java index 16d0abdde9a..2dcd0201277 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/InstallersPage.java @@ -60,12 +60,12 @@ private String getVersion(String id) { } protected void reload() { - for (InstallerItem library : group.getLibraries()) { - String libraryId = library.getComponentType().getPatchId(); + for (InstallerItem component : group.getComponents()) { + String libraryId = component.getComponentType().getPatchId(); if (controller.getSettings().containsKey(libraryId)) { - library.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false)); + component.versionProperty().set(new InstallerItem.InstalledState(getVersion(libraryId), false, false)); } else { - library.versionProperty().set(null); + component.versionProperty().set(null); } } if (!isNameModifiedByUser) { @@ -115,12 +115,12 @@ protected void onInstall() { private void setTxtNameWithLoaders() { StringBuilder nameBuilder = new StringBuilder(getTitle()); - for (InstallerItem library : group.getLibraries()) { - if (library.getComponentType() == GameComponentType.GAME - || !controller.getSettings().containsKey(library.getComponentType().getPatchId())) + for (InstallerItem component : group.getComponents()) { + if (component.getComponentType() == GameComponentType.GAME + || !controller.getSettings().containsKey(component.getComponentType().getPatchId())) continue; - String loaderName = switch (library.getComponentType()) { + String loaderName = switch (component.getComponentType()) { case FORGE -> "Forge"; case NEO_FORGE -> "NeoForge"; case CLEANROOM -> "Cleanroom"; 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 6693d777b92..ef7f9d233eb 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 @@ -85,36 +85,36 @@ public void loadInstance(HMCLGameInstance.Optional instance) { InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM); // Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine - for (InstallerItem item : group.getLibraries()) { + for (InstallerItem component : group.getComponents()) { // Skip fabric-api and quilt-api and legacyfabric-api - if (item.getComponentType().getPatchId().endsWith("-api")) { + if (component.getComponentType().getPatchId().endsWith("-api")) { continue; } - String libraryVersion = analyzer.getVersion(item.getComponentType()); + String libraryVersion = analyzer.getVersion(component.getComponentType()); if (libraryVersion != null) { - item.versionProperty().set(new InstallerItem.InstalledState( + component.versionProperty().set(new InstallerItem.InstalledState( libraryVersion, - !analyzer.isClear(item.getComponentType()), + !analyzer.isClear(component.getComponentType()), false )); } else { - item.versionProperty().set(null); + component.versionProperty().set(null); } - item.setOnInstall(() -> { - Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, item.getComponentType().getPatchId(), libraryVersion)); + component.setOnInstall(() -> { + Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType().getPatchId(), libraryVersion)); }); - item.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), item.getComponentType()) + component.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), component.getComponentType()) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) .start()); - itemsProperty().add(item); + itemsProperty().add(component); } // other third-party libraries which are unable to manage. From e53aaab3439190f2165fa9c0cab86f997f46c980 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 20:19:32 +0800 Subject: [PATCH 138/199] refactor(InstallerListPage): replace GameComponentAnalyzer usage with direct method calls for clarity --- .../hmcl/ui/instances/InstallerListPage.java | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) 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 ef7f9d233eb..5a0ac0a90d5 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 @@ -78,10 +78,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { HMCLGameRepository repository = gameInstance.getRepository(); - GameComponentAnalyzer analyzer = gameInstance.getAnalyzer(); - itemsProperty().clear(); - InstallerItem.InstallerItemGroup group = new InstallerItem.InstallerItemGroup(gameInstance.getVersion(), InstallerItem.Style.LIST_ITEM); // Conventional libraries: game, fabric, legacyfabric, forge, cleanroom, neoforge, liteloader, optifine @@ -92,12 +89,12 @@ public void loadInstance(HMCLGameInstance.Optional instance) { continue; } - String libraryVersion = analyzer.getVersion(component.getComponentType()); + @Nullable String libraryVersion = gameInstance.getComponentVersion(component.getComponentType()); if (libraryVersion != null) { component.versionProperty().set(new InstallerItem.InstalledState( libraryVersion, - !analyzer.isClear(component.getComponentType()), + !gameInstance.getAnalyzer().isClear(component.getComponentType()), false )); } else { @@ -111,14 +108,14 @@ public void loadInstance(HMCLGameInstance.Optional instance) { component.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), component.getComponentType()) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) - .withRunAsync(Schedulers.javafx(), () -> reloadCurrentInstance()) + .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) .start()); itemsProperty().add(component); } // other third-party libraries which are unable to manage. - for (GameComponentAnalyzer.Mark mark : analyzer) { + for (GameComponentAnalyzer.Mark mark : gameInstance.getAnalyzer()) { // we have done this library above. InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM); From 449364c4c8ef78c22cf82a51ff66dffceac085d1 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 20:23:51 +0800 Subject: [PATCH 139/199] refactor(HMCLGameInstance): simplify mod loader handling and improve logging clarity --- .../jackhuang/hmcl/game/HMCLGameInstance.java | 46 +++++++++---------- 1 file changed, 21 insertions(+), 25 deletions(-) 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 c95a3f39880..793ecff26a1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -464,30 +464,22 @@ private Image computeIconImage() { } } - GameInstanceManifest.Resolved resolvedManifest = getResolvedManifest(); - if (resolvedManifest.isModded()) { - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(resolvedManifest, null); - for (ModLoaderType type : ModLoaderType.values()) { - if (analyzer.has(type)) { - return GameInstanceIconType.getIconType(type).getIcon(); - } - } - - if (analyzer.has(GameComponentType.OPTIFINE)) - return GameInstanceIconType.OPTIFINE.getIcon(); + for (ModLoaderType modLoader : getModLoaders()) { + return GameInstanceIconType.getIconType(modLoader).getIcon(); } + if (hasComponent(GameComponentType.OPTIFINE)) + return GameInstanceIconType.OPTIFINE.getIcon(); + GameVersionNumber version = getVersion(); - if (!version.equals(GameVersionNumber.unknown())) { - if (version.isAprilFools()) { - return GameInstanceIconType.APRIL_FOOLS.getIcon(); - } else if (version instanceof GameVersionNumber.LegacySnapshot) { - return GameInstanceIconType.COMMAND.getIcon(); - } else if (version instanceof GameVersionNumber.Old) { - return GameInstanceIconType.CRAFT_TABLE.getIcon(); - } - } - return GameInstanceIconType.GRASS.getIcon(); + if (version.isAprilFools()) + return GameInstanceIconType.APRIL_FOOLS.getIcon(); + else if (version instanceof GameVersionNumber.LegacySnapshot) + return GameInstanceIconType.COMMAND.getIcon(); + else if (version instanceof GameVersionNumber.Old) + return GameInstanceIconType.CRAFT_TABLE.getIcon(); + else + return GameInstanceIconType.GRASS.getIcon(); } /// Clears the weak cache and notifies listeners. @@ -695,10 +687,14 @@ private static LoadResult loadGameSettingsFile(Path file) { JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA); switch (schemaResult.status()) { case MISSING -> LOG.warning("Missing schema in instance game settings: " + file); - case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue())); - case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual())); - case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); - case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); + case INVALID -> + LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue())); + case UNPARSEABLE -> + LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual())); + case UNEXPECTED_ID -> + LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); + case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> + LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { } } From 9f0956dfc5b9bdfab43c0129b283d2cb5cb41759 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 20:43:53 +0800 Subject: [PATCH 140/199] refactor(DefaultGameInstanceTest, DefaultLauncher, GameRepository): remove unused imports and simplify classpath resolution --- .../jackhuang/hmcl/game/GameRepository.java | 21 ----- .../hmcl/launch/DefaultLauncher.java | 44 ++++++++- .../hmcl/launch/LaunchClasspathResolver.java | 92 ------------------- .../hmcl/game/DefaultGameInstanceTest.java | 85 ----------------- 4 files changed, 43 insertions(+), 199 deletions(-) delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java 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 24c02638976..731cfe43fe1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -18,14 +18,10 @@ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.NotNullByDefault; -import java.nio.file.Files; import java.nio.file.Path; -import java.util.LinkedHashSet; import java.util.Optional; -import java.util.Set; /// Provides indexed access to local game instances and the filesystem layout used by those instances. /// @@ -137,21 +133,4 @@ default Path getInstanceRoot(GameInstanceID instanceId) { /// @return whether the instance was renamed boolean renameInstance(GameInstanceID from, GameInstanceID to); - /// Returns the classpath entries whose library files are present on disk. - /// - /// @param manifest the manifest whose libraries should be mapped to classpath entries - /// @return absolute classpath entries for existing non-native libraries - default Set getClasspath(GameInstanceManifest manifest) { - Set classpath = new LinkedHashSet<>(); - if (manifest.libraries() != null) { - for (Library library : manifest.libraries()) - if (library.appliesToCurrentEnvironment() && !library.isNative()) { - Path f = getLayout().getLibraryFile(manifest.id(), library); - if (Files.isRegularFile(f)) - classpath.add(FileUtils.getAbsolutePath(f)); - } - } - - return classpath; - } } 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 7beb0aacb61..6f6cb3e3552 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -42,6 +42,7 @@ import java.util.function.Supplier; import java.util.stream.Stream; +import static org.jackhuang.hmcl.game.GameComponentType.*; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -269,7 +270,7 @@ private Command generateCommandLine(Path nativeFolder) throws IOException { } // Library classpath used both for -cp and for rewriting old BootstrapLauncher ignore lists. - Set libraryClasspath = LaunchClasspathResolver.resolve(instance.getRepository(), manifest); + Set libraryClasspath = getClasspath(); if (instance.hasComponent(GameComponentType.CLEANROOM)) { libraryClasspath.removeIf(c -> c.contains("2.9.4-nightly-20150209")); @@ -799,6 +800,47 @@ else if (driver instanceof Renderer.Vulkan vulkanDriver) { return env; } + private Set getClasspath() { + GameInstanceID instanceId = instance.getId(); + GameRepositoryLayout layout = instance.getLayout(); + + boolean processOptiFine = instance.hasComponent(OPTIFINE) && (instance.hasComponent(LITELOADER) || instance.hasComponent(FORGE)); + @Nullable Path selectedOptiFineInstallerFile = null; + + Set classpath = new LinkedHashSet<>(); + for (Library library : manifest.getLibraries()) { + if (library.appliesToCurrentEnvironment() && !library.isNative()) { + if (processOptiFine) { + if (library.is("optifine", "OptiFine")) { + // Prefer the installer jar over the patch jar when both are present. + Library installer = new Library( + new Artifact("optifine", "OptiFine", library.version(), "installer")); + Path installerFile = layout.getLibraryFile(instanceId, installer); + if (Files.isRegularFile(installerFile)) { + selectedOptiFineInstallerFile = installerFile; + continue; + } + } else if (library.is("optifine", "launchwrapper-of")) { + // Drop OptiFine's private launchwrapper; Forge/LiteLoader supply their own. + continue; + } + } + + Path libraryFile = layout.getLibraryFile(instanceId, library); + if (Files.isRegularFile(libraryFile)) + classpath.add(FileUtils.getAbsolutePath(libraryFile)); + } + } + + // Re-append the installer last so OptiFine follows Forge when Forge has no patch entry. + if (selectedOptiFineInstallerFile != null && + // With ModLauncher, OptiFine is discovered via HMCLTransformerDiscoveryService, not classpath. + !GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass())) { + classpath.add(FileUtils.getAbsolutePath(selectedOptiFineInstallerFile)); + } + return classpath; + } + @Override public void makeLaunchScript(Path scriptFile) throws IOException { boolean isWindows = OperatingSystem.WINDOWS == OperatingSystem.CURRENT_OS; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java deleted file mode 100644 index 0766c0bb38b..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/LaunchClasspathResolver.java +++ /dev/null @@ -1,92 +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.launch; - -import org.jackhuang.hmcl.game.*; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jetbrains.annotations.NotNullByDefault; -import org.jetbrains.annotations.Nullable; - -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.LinkedHashSet; -import java.util.Set; - -import static org.jackhuang.hmcl.game.GameComponentType.*; - -/// Resolves the library classpath used for one launch attempt. -@NotNullByDefault -public final class LaunchClasspathResolver { - /// Prevents construction of this utility class. - private LaunchClasspathResolver() { - } - - /// Returns a mutable classpath containing installed libraries selected for this launch. - /// - /// When Forge or LiteLoader is present with OptiFine, the OptiFine *installer* jar is preferred - /// over the ordinary patch jar (the installer is what the Forge/LiteLoader tweaker stack expects). - /// OptiFine should load after Forge on the classpath: even when OptiFine is given higher install - /// priority, Forge may still appear without a patch entry, so the installer is re-appended at the - /// end. With ModLauncher the installer is omitted from this list because transformer discovery - /// loads it separately. - /// - /// OptiFine's custom `launchwrapper-of` artifact conflicts with the launchwrapper provided by - /// MinecraftForge, LiteLoader, or ModLoader and is therefore dropped. - /// - /// @param repository the repository that owns the installed libraries - /// @param manifest the effective launch manifest - /// @return a mutable insertion-ordered set of absolute classpath entries - public static Set resolve( - GameRepository repository, - GameInstanceManifest manifest) { - Set classpath = new LinkedHashSet<>(repository.getClasspath(manifest)); - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); - if (!analyzer.has(OPTIFINE) || (!analyzer.has(LITELOADER) && !analyzer.has(FORGE))) { - return classpath; - } - - // With ModLauncher, OptiFine is discovered via HMCLTransformerDiscoveryService, not classpath. - boolean removeFromClasspath = GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(manifest.mainClass()); - @Nullable Path selectedInstallerFile = null; - - for (Library library : manifest.getLibraries()) { - Path libraryFile = repository.getLayout().getLibraryFile(manifest.id(), library); - if (library.is("optifine", "OptiFine")) { - // Prefer the installer jar over the patch jar when both are present. - Library installer = new Library( - new Artifact("optifine", "OptiFine", library.version(), "installer")); - Path installerFile = repository.getLayout().getLibraryFile(manifest.id(), installer); - if (Files.exists(installerFile)) { - classpath.remove(FileUtils.getAbsolutePath(libraryFile)); - selectedInstallerFile = installerFile; - } - } else if (library.is("optifine", "launchwrapper-of")) { - // Drop OptiFine's private launchwrapper; Forge/LiteLoader supply their own. - classpath.remove(FileUtils.getAbsolutePath(libraryFile)); - } - } - - // Re-append the installer last so OptiFine follows Forge when Forge has no patch entry. - if (!removeFromClasspath - && selectedInstallerFile != null - && Files.isRegularFile(selectedInstallerFile)) { - classpath.add(FileUtils.getAbsolutePath(selectedInstallerFile)); - } - return classpath; - } -} 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 da5da663e13..825d894f3a6 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -22,7 +22,6 @@ import org.jackhuang.hmcl.download.MojangDownloadProvider; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameVerificationFixTask; -import org.jackhuang.hmcl.launch.LaunchClasspathResolver; import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask; import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask; import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask; @@ -40,7 +39,6 @@ import java.nio.file.Path; import java.util.List; import java.util.Optional; -import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; @@ -105,89 +103,6 @@ public void testModLauncherNormalizationDoesNotWriteBundledLibraries(@TempDir Pa assertEquals(launchManifest, LaunchManifestNormalizer.normalize(launchManifest)); } - /// Launch classpath resolution selects an installed OptiFine installer without changing the manifest. - @Test - public void testLaunchClasspathSelectsInstalledOptiFine(@TempDir Path tempDirectory) - throws IOException { - TestRepository repository = new TestRepository(tempDirectory); - GameInstanceID instanceId = new GameInstanceID("instance"); - Library forge = new Library(new Artifact("net.minecraftforge", "forge", "1.0")); - Library optiFine = new Library(new Artifact("optifine", "OptiFine", "1.0")); - Library optiFineLaunchWrapper = new Library( - new Artifact("optifine", "launchwrapper-of", "2.0")); - GameInstanceManifest manifest = new GameInstanceManifest(instanceId) - .withMainClass(GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN) - .withLibraries(List.of(forge, optiFine, optiFineLaunchWrapper)); - GameInstanceManifest launchManifest = repository.publish(instanceId, manifest) - .getResolvedManifest() - .launchManifest(); - Library installer = new Library(new Artifact("optifine", "OptiFine", "1.0", "installer")); - Path forgeFile = repository.getLayout().getLibraryFile(instanceId, forge); - Path optiFineFile = repository.getLayout().getLibraryFile(instanceId, optiFine); - Path optiFineLaunchWrapperFile = repository.getLayout() - .getLibraryFile(instanceId, optiFineLaunchWrapper); - Path installerFile = repository.getLayout().getLibraryFile(instanceId, installer); - Files.createDirectories(forgeFile.getParent()); - Files.createDirectories(installerFile.getParent()); - Files.createDirectories(optiFineLaunchWrapperFile.getParent()); - Files.write(forgeFile, new byte[]{1}); - Files.write(optiFineFile, new byte[]{1}); - Files.write(optiFineLaunchWrapperFile, new byte[]{1}); - Files.write(installerFile, new byte[]{1}); - - Set classpath = LaunchClasspathResolver.resolve(repository, launchManifest); - - assertEquals(Set.of( - forgeFile.toAbsolutePath().toString(), - installerFile.toAbsolutePath().toString()), classpath); - assertTrue(launchManifest.getLibraries().stream() - .anyMatch(library -> library.is("optifine", "OptiFine") - && library.classifier() == null)); - assertTrue(launchManifest.getLibraries().stream() - .anyMatch(library -> library.is("optifine", "launchwrapper-of"))); - } - - /// ModLauncher keeps an installed OptiFine installer outside its ordinary classpath. - @Test - public void testModLauncherClasspathOmitsInstalledOptiFine(@TempDir Path tempDirectory) - throws IOException { - TestRepository repository = new TestRepository(tempDirectory); - GameInstanceID instanceId = new GameInstanceID("instance"); - Library forge = new Library(new Artifact("net.minecraftforge", "forge", "1.0")); - Library optiFine = new Library(new Artifact("optifine", "OptiFine", "1.0")); - GameInstanceManifest manifest = new GameInstanceManifest(instanceId) - .withMainClass(GameComponentAnalyzer.MOD_LAUNCHER_MAIN) - .withLibraries(List.of(forge, optiFine)); - GameInstanceManifest launchManifest = repository.publish(instanceId, manifest) - .getResolvedManifest() - .launchManifest(); - Library installer = new Library(new Artifact("optifine", "OptiFine", "1.0", "installer")); - Library transformerService = launchManifest.getLibraries().stream() - .filter(library -> library.is( - "org.jackhuang.hmcl", "transformer-discovery-service")) - .findAny() - .orElseThrow(); - Path forgeFile = repository.getLayout().getLibraryFile(instanceId, forge); - Path optiFineFile = repository.getLayout().getLibraryFile(instanceId, optiFine); - Path installerFile = repository.getLayout().getLibraryFile(instanceId, installer); - Path transformerServiceFile = repository.getLayout() - .getLibraryFile(instanceId, transformerService); - Files.createDirectories(forgeFile.getParent()); - Files.createDirectories(installerFile.getParent()); - Files.createDirectories(transformerServiceFile.getParent()); - Files.write(forgeFile, new byte[]{1}); - Files.write(optiFineFile, new byte[]{1}); - Files.write(installerFile, new byte[]{1}); - Files.write(transformerServiceFile, new byte[]{1}); - - Set classpath = LaunchClasspathResolver.resolve(repository, launchManifest); - - assertEquals(Set.of( - forgeFile.toAbsolutePath().toString(), - transformerServiceFile.toAbsolutePath().toString()), classpath); - assertTrue(launchManifest.getLibraries().contains(optiFine)); - } - /// Saving a manifest preserves its root flag and pending patches without baking in normalization. @Test public void testSavePreservesManifestPatchStructure(@TempDir Path tempDirectory) throws Exception { From 48afce0a8fde1b0fe389de945d8a05e5ecb2d848 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 20:45:25 +0800 Subject: [PATCH 141/199] refactor(DefaultLauncher): update Javadoc comments for clarity and consistency --- .../org/jackhuang/hmcl/launch/DefaultLauncher.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) 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 6f6cb3e3552..2a4e21db677 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -439,13 +439,11 @@ protected List getDefaultGameArguments() { return Arguments.DEFAULT_GAME_ARGUMENTS; } - /** - * Do something here. - * i.e. - * -Dminecraft.launcher.version=<Your launcher name> - * -Dminecraft.launcher.brand=<Your launcher version> - * -Dlog4j.configurationFile=<Your custom log4j configuration> - */ + /// Do something here. + /// i.e. + /// -Dminecraft.launcher.version= + /// -Dminecraft.launcher.brand= + /// -Dlog4j.configurationFile= protected void appendJvmArgs(CommandBuilder result) { } From 4d65f50573f4f07eff851ea94d4793ba78b7a0fd Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 21:00:32 +0800 Subject: [PATCH 142/199] refactor(DefaultDependencyManager, LaunchManifestNormalizer): improve component handling and simplify version checks --- .../hmcl/download/DefaultDependencyManager.java | 10 ++++------ .../jackhuang/hmcl/game/LaunchManifestNormalizer.java | 2 -- 2 files changed, 4 insertions(+), 8 deletions(-) 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 767cbd02e9e..c37b544d3d1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -129,22 +129,20 @@ public Task checkPatchCompletionAsync( List> tasks = new ArrayList<>(0); GameVersionNumber detectedVersion = instance.getVersion(); - if (detectedVersion == GameVersionNumber.unknown()) return null; + if (detectedVersion.equals(GameVersionNumber.unknown())) return null; String gameVersion = detectedVersion.toString(); GameInstanceManifest original = instance.getManifest(); - GameInstanceManifest.Resolved resolvedInstanceManifest = instance.getResolvedManifest(); - GameComponentAnalyzer analyzer = instance.getAnalyzer(); for (GameComponentType type : GameComponentType.values()) { - if (!analyzer.has(type)) + if (!instance.hasComponent(type)) continue; if (type == GameComponentType.OPTIFINE) { - String optifinePatchVersion = Optional.ofNullable(analyzer.getVersion(type)) .map(optifineVersion -> { + String optifinePatchVersion = Optional.ofNullable(instance.getComponentVersion(type)) .map(optifineVersion -> { Matcher matcher = Pattern.compile("^([0-9.]+)_(?HD_.+)$").matcher(optifineVersion); return matcher.find() ? matcher.group("optifine") : optifineVersion; }) - .orElseGet(() -> resolvedInstanceManifest.standaloneManifest().getPatches().stream() + .orElseGet(() -> instance.getResolvedManifest().standaloneManifest().getPatches().stream() .filter(patch -> "optifine".equals(patch.id())) .findAny() .map(GameInstancePatch::version) 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 44c89d29255..b8e3938abda 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -70,8 +70,6 @@ public static GameInstanceManifest normalize(GameInstanceManifest manifest) { // DefaultLauncher when building the process command). normalized = normalizeBootstrapLauncher(normalized); } - // Vanilla and Fabric/Quilt need no loader-specific argument repair here; nothing currently - // coexists with Fabric the way OptiFine does with Forge/LiteLoader. return removeLegacyLog4jPatch(normalized); } From f77ad9397d00cfe396a6c874309fa7723ce40a84 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 21:25:06 +0800 Subject: [PATCH 143/199] Deduplicate launch libraries at resolve and repair loader args in LauncherHelper Assisted-by: grok-build:grok-4.5 --- .../game/DefaultGameRepositorySnapshot.java | 13 ++- .../hmcl/game/LaunchManifestNormalizer.java | 99 ++++++++++--------- 2 files changed, 60 insertions(+), 52 deletions(-) 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 a29847edd72..c419a3bce6f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -211,20 +211,23 @@ public DefaultGameRepositorySnapshot clone() { return newSnapshot; } - /// Resolves official-layout inheritance and patches, then normalizes the final launch view. + /// 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 = resolveStructure(manifest, new HashSet<>()); - GameInstanceManifest normalizedLaunchManifest = - LaunchManifestNormalizer.normalize(resolved.launchManifest()); + GameInstanceManifest launchManifest = + LaunchManifestNormalizer.deduplicateLibraries(resolved.launchManifest()); return new GameInstanceManifest.Resolved( - resolved.unresolved(), normalizedLaunchManifest, resolved.standaloneManifest()); + resolved.unresolved(), launchManifest, resolved.standaloneManifest()); } - /// Resolves official-layout inheritance and patches without launch compatibility normalization. + /// Resolves official-layout inheritance and patches without launch-library deduplication. /// /// @param manifest the manifest to resolve /// @param resolvedSoFar instance ids already visited in the inheritance chain 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 b8e3938abda..d4c66b220f4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -28,50 +28,73 @@ import java.util.List; import java.util.Optional; -/// Normalizes a structurally resolved manifest into the stable view consumed by launch-time code. +/// Launch-manifest library and argument adjustments used at resolve time and launch time. /// -/// Normalization depends only on manifest content. Filesystem-dependent compatibility adjustments -/// are performed separately immediately before launch. +/// [#deduplicateLibraries(GameInstanceManifest)] runs when a repository resolves a launch view so +/// consumers share a stable library list. Loader-specific argument repairs run later via +/// [#repairForLaunch(GameInstanceManifest)] (for example from `LauncherHelper`) and do not depend on +/// the installed filesystem. Path-sensitive BootstrapLauncher ignore-list fixes remain in +/// `DefaultLauncher`. @NotNullByDefault public final class LaunchManifestNormalizer { /// Prevents construction of this utility class. private LaunchManifestNormalizer() { } - /// Normalizes a resolved launch manifest. + /// Removes redundant libraries from a structurally resolved launch manifest. /// - /// The input must not contain inheritance or pending patches. The returned manifest has duplicate - /// libraries removed and loader-specific arguments and libraries repaired. The input is unchanged. + /// The input must not contain inheritance or pending patches. The input is unchanged. /// /// @param manifest the structurally resolved launch manifest - /// @return the normalized launch manifest + /// @return the manifest with duplicate libraries collapsed /// @throws IllegalArgumentException if the manifest still contains inheritance or pending patches - public static GameInstanceManifest normalize(GameInstanceManifest manifest) { - if (manifest.inheritsFrom() != null || !manifest.getPatches().isEmpty()) { - throw new IllegalArgumentException("Launch manifest must be structurally resolved"); - } + public static GameInstanceManifest deduplicateLibraries(GameInstanceManifest manifest) { + requireStructurallyResolved(manifest); + return uniqueLibraries(manifest); + } - GameInstanceManifest normalized = uniqueLibraries(manifest); - @Nullable String mainClass = normalized.mainClass(); + /// Applies loader-specific argument and library repairs for one launch attempt. + /// + /// Expects a structurally resolved launch manifest, typically after + /// [#deduplicateLibraries(GameInstanceManifest)]. Builds a single [GameComponentAnalyzer] for + /// the whole repair. The input is unchanged. + /// + /// @param manifest the launch manifest to repair + /// @return the repaired launch manifest + /// @throws IllegalArgumentException if the manifest still contains inheritance or pending patches + public static GameInstanceManifest repairForLaunch(GameInstanceManifest manifest) { + requireStructurallyResolved(manifest); + + GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + GameInstanceManifest repaired = manifest; + @Nullable String mainClass = repaired.mainClass(); if (GameComponentAnalyzer.LAUNCH_WRAPPER_MAIN.equals(mainClass)) { // LaunchWrapper era (Forge/LiteLoader/OptiFine on 1.12 and earlier, and mixed stacks). - normalized = normalizeLaunchWrapper(normalized, true); - if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(normalized.mainClass())) { + repaired = repairLaunchWrapper(repaired, analyzer, true); + if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(repaired.mainClass())) { // OptiFine + ModLauncher may promote mainClass off LaunchWrapper. - normalized = normalizeModLauncher(normalized); + repaired = repairModLauncher(repaired, analyzer); } } else if (GameComponentAnalyzer.MOD_LAUNCHER_MAIN.equals(mainClass)) { // Forge 1.13+ with OptiFine on ModLauncher. - normalized = normalizeModLauncher(normalized); + repaired = repairModLauncher(repaired, analyzer); } else if (GameComponentAnalyzer.BOOTSTRAP_LAUNCHER_MAIN.equals(mainClass)) { // Forge / NeoForge 1.17+ BootstrapLauncher ignore-list form that does not need the // installed filesystem (path-sensitive fixes for older BootstrapLauncher run in // DefaultLauncher when building the process command). - normalized = normalizeBootstrapLauncher(normalized); + repaired = repairBootstrapLauncher(repaired, analyzer); } + // Vanilla and Fabric/Quilt need no loader-specific argument repair here. - return removeLegacyLog4jPatch(normalized); + return removeLegacyLog4jPatch(repaired); + } + + /// Requires a fully folded launch manifest without inheritance or pending patches. + private static void requireStructurallyResolved(GameInstanceManifest manifest) { + if (manifest.inheritsFrom() != null || !manifest.getPatches().isEmpty()) { + throw new IllegalArgumentException("Launch manifest must be structurally resolved"); + } } /// Repairs LaunchWrapper tweak-class configuration. @@ -79,14 +102,10 @@ public static GameInstanceManifest normalize(GameInstanceManifest manifest) { /// Installing Forge can replace the full game argument list in the version JSON, which drops /// LiteLoader and OptiFine tweakers. Compatible tweak classes are re-inserted in deterministic /// order when still required. - /// - /// @param manifest the resolved manifest - /// @param reorderTweakClass whether retained tweak classes are moved to their required positions - /// @return the repaired manifest - private static GameInstanceManifest normalizeLaunchWrapper( + private static GameInstanceManifest repairLaunchWrapper( GameInstanceManifest manifest, + GameComponentAnalyzer analyzer, boolean reorderTweakClass) { - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); GameInstanceLibraryBuilder builder = new GameInstanceLibraryBuilder(manifest); @Nullable String mainClass = null; @@ -145,16 +164,14 @@ private static GameInstanceManifest normalizeLaunchWrapper( } } - GameInstanceManifest normalized = builder.build(); - return mainClass == null ? normalized : normalized.withMainClass(mainClass); + GameInstanceManifest repaired = builder.build(); + return mainClass == null ? repaired : repaired.withMainClass(mainClass); } /// Adds the transformer discovery service required by Forge and OptiFine on ModLauncher. - /// - /// @param manifest the resolved manifest - /// @return the repaired manifest - private static GameInstanceManifest normalizeModLauncher(GameInstanceManifest manifest) { - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); + private static GameInstanceManifest repairModLauncher( + GameInstanceManifest manifest, + GameComponentAnalyzer analyzer) { if (!analyzer.has(GameComponentType.FORGE) || !analyzer.has(GameComponentType.OPTIFINE)) { return manifest; } @@ -189,12 +206,10 @@ private static GameInstanceManifest normalizeModLauncher(GameInstanceManifest ma /// entry, so it is enough to ensure the primary jar name is listed. Older versions match /// substrings against full paths and are repaired in `DefaultLauncher` using the launch-time /// library classpath. - /// - /// @param manifest the resolved manifest - /// @return the repaired manifest - private static GameInstanceManifest normalizeBootstrapLauncher(GameInstanceManifest manifest) { + private static GameInstanceManifest repairBootstrapLauncher( + GameInstanceManifest manifest, + GameComponentAnalyzer analyzer) { // Fix wrong configurations when launching 1.17+ with Forge / NeoForge. - GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); if (!analyzer.has(GameComponentType.FORGE) && !analyzer.has(GameComponentType.NEO_FORGE)) { return manifest; } @@ -224,10 +239,6 @@ private static GameInstanceManifest normalizeBootstrapLauncher(GameInstanceManif } /// Returns whether a comma-separated list contains the exact requested value. - /// - /// @param values the comma-separated values - /// @param target the value to find - /// @return whether `target` is present private static boolean containsCommaSeparatedValue(String values, String target) { for (String value : values.split(",")) { if (target.equals(value)) { @@ -241,9 +252,6 @@ private static boolean containsCommaSeparatedValue(String values, String target) /// /// HMCL once injected `log4j-patch` to mitigate the Log4j vulnerability. The launcher now /// rewrites `log4j2.xml` instead, so the leftover library entry is dropped. - /// - /// @param manifest the normalized manifest - /// @return the manifest without the obsolete first library, when present private static GameInstanceManifest removeLegacyLog4jPatch(GameInstanceManifest manifest) { List libraries = manifest.getLibraries(); if (libraries.isEmpty()) { @@ -267,9 +275,6 @@ private static GameInstanceManifest removeLegacyLog4jPatch(GameInstanceManifest /// 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. - /// - /// @param manifest the resolved manifest - /// @return the manifest with redundant libraries removed private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifest) { List libraries = new ArrayList<>(); SimpleMultimap> indexes = From 0436fd335f704a0ff072ad446a0c584f495e5818 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 21:42:47 +0800 Subject: [PATCH 144/199] refactor(LaunchManifestNormalizer, DefaultDependencyManager, DefaultGameInstanceTest): simplify library handling and improve launch manifest repairs --- .../jackhuang/hmcl/game/LauncherHelper.java | 8 +- .../download/DefaultDependencyManager.java | 6 +- .../hmcl/game/CompatibilityRule.java | 4 - .../game/DefaultGameRepositorySnapshot.java | 93 ++++++++++++++---- .../hmcl/game/LaunchManifestNormalizer.java | 95 +------------------ .../hmcl/launch/DefaultLauncher.java | 4 +- .../hmcl/game/DefaultGameInstanceTest.java | 38 ++------ 7 files changed, 100 insertions(+), 148 deletions(-) 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 e256f4aa3a7..8ed284d6b93 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LauncherHelper.java @@ -156,7 +156,9 @@ private void launch0() { HMCLGameRepository repository = repository(); DefaultDependencyManager dependencyManager = repository.getDependency(); - var launchManifest = new AtomicReference<>(gameInstance.getResolvedManifest().launchManifest()); + // Resolve already deduplicated libraries; apply loader-specific argument repairs for this launch. + var launchManifest = new AtomicReference<>(LaunchManifestNormalizer.repairForLaunch( + gameInstance.getResolvedManifest().launchManifest())); boolean integrityCheck = gameInstance.unmarkLaunchedAbnormally(); CountDownLatch launchingLatch = new CountDownLatch(1); List javaAgents = new ArrayList<>(0); @@ -196,9 +198,7 @@ private void launch0() { Library lib = NativePatcher.getWindowsMesaLoader(java, renderer, OperatingSystem.SYSTEM_VERSION); if (lib == null) return null; - GameRepository gameRepository = dependencyManager.getGameRepository(); - GameInstanceManifest manifest = launchManifest.get(); - Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), lib); + Path file = gameInstance.getLayout().getLibraryFile(gameInstance.getId(), lib); if (file.toAbsolutePath().toString().indexOf('=') >= 0) { LOG.warning("Invalid character '=' in the libraries directory path, unable to attach software renderer loader"); return null; 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 c37b544d3d1..7973f2dd994 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -262,11 +262,11 @@ public UnsupportedLibraryInstallerException() { public Task removeLibraryAsync(GameInstanceManifest manifest, GameComponentType componentType) { // Library removal operates on a standalone manifest so inherited launch metadata is retained. return Task.supplyAsync(() -> { - GameInstanceManifest independentVersion = repository.resolve(manifest).standaloneManifest(); - GameVersionNumber gameVersion = repository.getGameVersion(independentVersion) + GameInstanceManifest standaloneManifest = repository.resolve(manifest).standaloneManifest(); + GameVersionNumber gameVersion = repository.getGameVersion(standaloneManifest) .map(GameVersionNumber::asGameVersion) .orElse(null); - return GameComponentAnalyzer.analyze(independentVersion, gameVersion).removeLibrary(componentType); + return GameComponentAnalyzer.analyze(standaloneManifest, gameVersion).removeLibrary(componentType); }); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/CompatibilityRule.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/CompatibilityRule.java index 7e1549edbce..b52566ad7aa 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/CompatibilityRule.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/CompatibilityRule.java @@ -76,10 +76,6 @@ public static boolean appliesToCurrentEnvironment(Collection return action == Action.ALLOW; } - public static boolean equals(Collection rules1, Collection rules2) { - return Objects.hashCode(rules1) == Objects.hashCode(rules2); - } - @Override public boolean equals(Object o) { if (this == o) return true; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java index c419a3bce6f..6935890c620 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -17,18 +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.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashSet; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.TreeMap; +import java.util.*; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -220,11 +215,15 @@ public DefaultGameRepositorySnapshot clone() { /// @return the resolved manifest views /// @throws NoSuchGameInstanceException if an inherited parent is missing from this snapshot public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { - GameInstanceManifest.Resolved resolved = resolveStructure(manifest, new HashSet<>()); - GameInstanceManifest launchManifest = - LaunchManifestNormalizer.deduplicateLibraries(resolved.launchManifest()); - return new GameInstanceManifest.Resolved( - resolved.unresolved(), launchManifest, resolved.standaloneManifest()); + GameInstanceManifest.Resolved resolved = resolve(manifest, new HashSet<>()); + GameInstanceManifest launchManifest = uniqueLibraries(resolved.launchManifest()); + if (launchManifest != resolved.launchManifest()) { + resolved = new GameInstanceManifest.Resolved( + resolved.unresolved(), + launchManifest, + resolved.standaloneManifest()); + } + return resolved; } /// Resolves official-layout inheritance and patches without launch-library deduplication. @@ -233,7 +232,7 @@ 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 resolveStructure( + private GameInstanceManifest.Resolved resolve( GameInstanceManifest manifest, Set resolvedSoFar) throws NoSuchGameInstanceException { GameInstanceManifest launchManifest; @@ -267,7 +266,7 @@ private GameInstanceManifest.Resolved resolveStructure( // It is supposed to auto-install a version in getVersion. GameInstanceManifest.Resolved parentResolved = - resolveStructure(parentInstance.getManifest(), resolvedSoFar); + resolve(parentInstance.getManifest(), resolvedSoFar); launchManifest = manifest.merge(parentResolved.launchManifest()); standaloneManifest = addPatches( addPatches(parentResolved.standaloneManifest(), Collections.singleton(manifest.toPatch())), @@ -294,6 +293,68 @@ private GameInstanceManifest.Resolved resolveStructure( return new GameInstanceManifest.Resolved(manifest, launchManifest, standaloneManifest); } + /// Removes redundant library declarations while retaining rule-distinct variants. + /// + /// When two libraries share the same `groupId:artifactId` and equal compatibility rules, the + /// newer version wins. When versions are equal and the coordinate objects compare equal, the + /// declaration with the longer serialized JSON is kept (more metadata is treated as richer). + /// Equal id and version with unequal coordinate payloads (for example distinct `text2speech` + /// library vs native entries) are both retained. + private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifest) { + List libraries = new ArrayList<>(); + SimpleMultimap> indexes = + new SimpleMultimap<>(HashMap::new, ArrayList::new); + + for (Library library : manifest.getLibraries()) { + String id = library.groupId() + ":" + library.artifactId(); + + if (!indexes.containsKey(id)) { + indexes.put(id, libraries.size()); + libraries.add(library); + continue; + } + + boolean duplicate = false; + for (int otherIndex : indexes.get(id)) { + Library other = libraries.get(otherIndex); + // Rules differ: keep both (platform-specific variants). + if (Objects.hashCode(library.rules()) != Objects.hashCode(other.rules())) { + continue; + } + + // Rules equal: drop the older version. + int comparison = VersionNumber.compare(library.version(), other.version()); + if (comparison > 0) { + libraries.set(otherIndex, library); + } else if (comparison == 0) { + // Same library id and version: collapse true duplicates. + if (library.equals(other)) { + String otherSerialized = JsonUtils.GSON.toJson(other); + String serialized = JsonUtils.GSON.toJson(library); + // Prefer the entry with more serialized metadata when coordinates equal. + if (serialized.length() > otherSerialized.length()) { + libraries.set(otherIndex, library); + } + } else { + // Same id/version but not equal (e.g. text2speech jar vs natives): keep both. + continue; + } + } + duplicate = true; + break; + } + + if (!duplicate) { + indexes.put(id, libraries.size()); + libraries.add(library); + } + } + + return libraries.size() == manifest.getLibraries().size() + ? manifest + : manifest.withLibraries(libraries); + } + private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @Nullable Collection additional) { if (additional == null || additional.isEmpty()) { return manifest; 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 d4c66b220f4..4f4763a2dd0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -17,21 +17,16 @@ */ package org.jackhuang.hmcl.game; -import org.jackhuang.hmcl.util.SimpleMultimap; -import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.versioning.VersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; -import java.util.ArrayList; -import java.util.HashMap; import java.util.List; import java.util.Optional; /// Launch-manifest library and argument adjustments used at resolve time and launch time. /// -/// [#deduplicateLibraries(GameInstanceManifest)] runs when a repository resolves a launch view so -/// consumers share a stable library list. Loader-specific argument repairs run later via +/// Loader-specific argument repairs run later via /// [#repairForLaunch(GameInstanceManifest)] (for example from `LauncherHelper`) and do not depend on /// the installed filesystem. Path-sensitive BootstrapLauncher ignore-list fixes remain in /// `DefaultLauncher`. @@ -41,29 +36,18 @@ public final class LaunchManifestNormalizer { private LaunchManifestNormalizer() { } - /// Removes redundant libraries from a structurally resolved launch manifest. - /// - /// The input must not contain inheritance or pending patches. The input is unchanged. - /// - /// @param manifest the structurally resolved launch manifest - /// @return the manifest with duplicate libraries collapsed - /// @throws IllegalArgumentException if the manifest still contains inheritance or pending patches - public static GameInstanceManifest deduplicateLibraries(GameInstanceManifest manifest) { - requireStructurallyResolved(manifest); - return uniqueLibraries(manifest); - } - /// Applies loader-specific argument and library repairs for one launch attempt. /// - /// Expects a structurally resolved launch manifest, typically after - /// [#deduplicateLibraries(GameInstanceManifest)]. Builds a single [GameComponentAnalyzer] for + /// Expects a structurally resolved launch manifest. Builds a single [GameComponentAnalyzer] for /// the whole repair. The input is unchanged. /// /// @param manifest the launch manifest to repair /// @return the repaired launch manifest /// @throws IllegalArgumentException if the manifest still contains inheritance or pending patches public static GameInstanceManifest repairForLaunch(GameInstanceManifest manifest) { - requireStructurallyResolved(manifest); + if (manifest.inheritsFrom() != null || !manifest.getPatches().isEmpty()) { + throw new IllegalArgumentException("Launch manifest must be structurally resolved"); + } GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(manifest, null); GameInstanceManifest repaired = manifest; @@ -90,13 +74,6 @@ public static GameInstanceManifest repairForLaunch(GameInstanceManifest manifest return removeLegacyLog4jPatch(repaired); } - /// Requires a fully folded launch manifest without inheritance or pending patches. - private static void requireStructurallyResolved(GameInstanceManifest manifest) { - if (manifest.inheritsFrom() != null || !manifest.getPatches().isEmpty()) { - throw new IllegalArgumentException("Launch manifest must be structurally resolved"); - } - } - /// Repairs LaunchWrapper tweak-class configuration. /// /// Installing Forge can replace the full game argument list in the version JSON, which drops @@ -267,66 +244,4 @@ 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 (!CompatibilityRule.equals(library.rules(), 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/launch/DefaultLauncher.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java index 2a4e21db677..cd0463dc612 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -531,8 +531,8 @@ public void extractLog4jConfigurationFile() throws IOException { /// for this launch, loose tokens are replaced with exact installed paths (or portable /// `${library_directory}` placeholders). `${primary_jar}` is always retained for Jigsaw. /// - /// BootstrapLauncher 0.1.17+ only matches file names; those manifests are repaired at resolve - /// time by [LaunchManifestNormalizer]. + /// BootstrapLauncher 0.1.17+ only matches file names; those manifests are repaired for launch by + /// [LaunchManifestNormalizer#repairForLaunch(GameInstanceManifest)]. /// /// @param jvmArguments JVM arguments from the launch manifest /// @param libraryClasspath absolute library classpath entries for this launch (without primary jar) 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 825d894f3a6..2493de351e6 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -55,34 +55,9 @@ @NotNullByDefault public final class DefaultGameInstanceTest { - /// Resolve normalizes only the derived launch view and leaves the stored patch structure intact. + /// Launch repair for ModLauncher adds support metadata without materializing bundled files. @Test - public void testResolveNormalizesLaunchWithoutChangingStoredPatches(@TempDir Path tempDirectory) { - TestRepository repository = new TestRepository(tempDirectory); - GameInstanceID instanceId = new GameInstanceID("instance"); - Library oldLibrary = new Library(new Artifact("example", "library", "1.0")); - Library newLibrary = new Library(new Artifact("example", "library", "2.0")); - List patches = List.of(new GameInstancePatch( - "loader", null, 0, null, null, List.of(oldLibrary, newLibrary))); - GameInstanceManifest storedManifest = new GameInstanceManifest(instanceId) - .withRoot(true) - .withPatches(patches); - TestGameInstance instance = repository.publish(instanceId, storedManifest); - - GameInstanceManifest.Resolved resolved = instance.getResolvedManifest(); - - assertEquals(1, resolved.launchManifest().getLibraries().size()); - assertEquals("2.0", resolved.launchManifest().getLibraries().getFirst().version()); - assertEquals(patches, resolved.standaloneManifest().getPatches()); - assertEquals(storedManifest, instance.getManifest()); - assertEquals( - resolved.launchManifest(), - LaunchManifestNormalizer.normalize(resolved.launchManifest())); - } - - /// ModLauncher normalization adds support metadata without materializing bundled files. - @Test - public void testModLauncherNormalizationDoesNotWriteBundledLibraries(@TempDir Path tempDirectory) { + public void testModLauncherLaunchRepairDoesNotWriteBundledLibraries(@TempDir Path tempDirectory) { TestRepository repository = new TestRepository(tempDirectory.resolve("game")); GameInstanceID instanceId = new GameInstanceID("instance"); GameInstanceManifest manifest = new GameInstanceManifest(instanceId) @@ -92,7 +67,12 @@ public void testModLauncherNormalizationDoesNotWriteBundledLibraries(@TempDir Pa new Library(new Artifact("optifine", "OptiFine", "1.0")))); TestGameInstance instance = repository.publish(instanceId, manifest); GameInstanceManifest launchManifest = instance.getResolvedManifest().launchManifest(); - Library transformerService = launchManifest.getLibraries().stream() + assertTrue(launchManifest.getLibraries().stream() + .noneMatch(library -> library.is( + "org.jackhuang.hmcl", "transformer-discovery-service"))); + + GameInstanceManifest repaired = LaunchManifestNormalizer.repairForLaunch(launchManifest); + Library transformerService = repaired.getLibraries().stream() .filter(library -> library.is( "org.jackhuang.hmcl", "transformer-discovery-service")) .findAny() @@ -100,7 +80,7 @@ public void testModLauncherNormalizationDoesNotWriteBundledLibraries(@TempDir Pa Path transformerFile = repository.getLayout().getLibraryFile(instanceId, transformerService); assertFalse(Files.exists(transformerFile)); - assertEquals(launchManifest, LaunchManifestNormalizer.normalize(launchManifest)); + assertEquals(repaired, LaunchManifestNormalizer.repairForLaunch(repaired)); } /// Saving a manifest preserves its root flag and pending patches without baking in normalization. From 4bf96dab56e630203954d135e436b3aa39dc587a Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 21:48:29 +0800 Subject: [PATCH 145/199] refactor(FabricInstallTask, ForgeInstallTask, GameAssetDownloadTask, GameDownloadTask, GameInstallTask, GameLibrariesTask, Instances, OptiFineInstallTask, QuiltInstallTask): streamline manifest resolution and improve main class handling --- .../org/jackhuang/hmcl/ui/instances/Instances.java | 2 +- .../hmcl/download/fabric/FabricInstallTask.java | 2 +- .../hmcl/download/forge/ForgeInstallTask.java | 2 +- .../hmcl/download/game/GameAssetDownloadTask.java | 13 ++++++------- .../hmcl/download/game/GameDownloadTask.java | 4 ++-- .../hmcl/download/game/GameInstallTask.java | 10 +++++----- .../hmcl/download/game/GameLibrariesTask.java | 2 +- .../hmcl/download/optifine/OptiFineInstallTask.java | 2 +- .../hmcl/download/quilt/QuiltInstallTask.java | 2 +- .../jackhuang/hmcl/game/GameInstanceManifest.java | 8 -------- 10 files changed, 19 insertions(+), 28 deletions(-) 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 b97821079af..0252d92e3b7 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 @@ -248,7 +248,7 @@ public static void updateInstance(HMCLGameInstance gameInstance) { public static void updateGameAssets(HMCLGameInstance gameInstance) { TaskExecutor executor = new GameAssetDownloadTask( gameInstance.getRepository().getDependency(), - gameInstance.getManifest(), + gameInstance.getResolvedManifest().launchManifest(), GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true).executor(); Controllers.taskDialog(executor, i18n("instance.manage.redownload_assets_index"), TaskCancellationAction.NO_CANCEL); 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 88206744c19..d48b558761b 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 @@ -62,7 +62,7 @@ public boolean doPreExecute() { @Override public void preExecute() throws Exception { - if (!Objects.equals("net.minecraft.client.main.Main", manifest.resolve(dependencyManager.getGameRepository()).mainClass())) + if (!Objects.equals("net.minecraft.client.main.Main", dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass())) throw new UnsupportedInstallationException(FABRIC_NOT_COMPATIBLE_WITH_FORGE); } 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 5409344e908..1cb5629e8d5 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 @@ -100,7 +100,7 @@ public Collection> getDependencies() { @Override public void execute() throws IOException, VersionMismatchException, UnsupportedInstallationException { - String originalMainClass = manifest.resolve(dependencyManager.getGameRepository()).mainClass(); + String originalMainClass = dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass(); if (GameVersionNumber.compare("1.13", remote.getGameVersion()) <= 0) { // Forge 1.13 is not compatible with fabric. if (!GameComponentAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) 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 d0e72cc3397..4496f133a3c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java @@ -49,15 +49,14 @@ public final class GameAssetDownloadTask extends Task { private final List> dependents = new ArrayList<>(1); private final List> dependencies = new ArrayList<>(); - /** - * Constructor. - * - * @param dependencyManager the dependency manager that can provides {@link GameRepository} - * @param manifest the game version - */ + + /// Constructor. + /// + /// @param dependencyManager the dependency manager that can provides [GameRepository] + /// @param manifest the game version public GameAssetDownloadTask(AbstractDependencyManager dependencyManager, GameInstanceManifest manifest, boolean forceDownloadingIndex, boolean integrityCheck) { this.dependencyManager = dependencyManager; - this.manifest = manifest.resolve(dependencyManager.getGameRepository()); + this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); this.assetIndexInfo = this.manifest.getAssetIndex(); 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 0b214a7b08f..f6427318a84 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 @@ -60,7 +60,7 @@ public GameDownloadTask( GameInstanceManifest manifest) { this.dependencyManager = dependencyManager; this.gameVersion = gameVersion; - this.manifest = manifest.resolve(dependencyManager.getGameRepository()); + this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); this.jar = null; setSignificance(TaskSignificance.MODERATE); @@ -79,7 +79,7 @@ public GameDownloadTask( Path jar) { this.dependencyManager = dependencyManager; this.gameVersion = gameVersion; - this.manifest = manifest.resolve(dependencyManager.getGameRepository()); + this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); this.jar = jar; 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 55e03b8aaae..ec2fda7f264 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 @@ -71,16 +71,16 @@ public void execute() throws Exception { GameInstancePatch.PRIORITY_MC).withJar(null); setResult(patch); - GameInstanceManifest version = new GameInstanceManifest(this.manifest.id()).addPatch(patch); + GameInstanceManifest newManifest = new GameInstanceManifest(this.manifest.id()).addPatch(patch); dependencies.add(Task.allOf( - new GameDownloadTask(dependencyManager, remote.getGameVersion(), version), + new GameDownloadTask(dependencyManager, remote.getGameVersion(), newManifest), Task.allOf( - new GameAssetDownloadTask(dependencyManager, version, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true), - new GameLibrariesTask(dependencyManager, version, true) + new GameAssetDownloadTask(dependencyManager, newManifest, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true), + new GameLibrariesTask(dependencyManager, newManifest, true) ).withRunAsync(() -> { // ignore failure }) - ).thenComposeAsync(gameRepository.saveAsync(version))); + ).thenComposeAsync(gameRepository.saveAsync(newManifest))); } } 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 65d68c386ac..a3f5040d5d0 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, manifest.resolve(dependencyManager.getGameRepository()).getLibraries()); + this(dependencyManager, manifest, integrityCheck, dependencyManager.getGameRepository().resolve(manifest).launchManifest().getLibraries()); } /** 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 b24032b8867..4de1ca45889 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 @@ -122,7 +122,7 @@ public boolean isRelyingOnDependencies() { @Override public void execute() throws Exception { - String originalMainClass = manifest.resolve(dependencyManager.getGameRepository()).mainClass(); + String originalMainClass = dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass(); if (!GameComponentAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) throw new UnsupportedInstallationException(UnsupportedInstallationException.UNSUPPORTED_LAUNCH_WRAPPER); 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 8a4fa50c965..e747533e098 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/quilt/QuiltInstallTask.java @@ -61,7 +61,7 @@ public boolean doPreExecute() { @Override public void preExecute() throws Exception { - if (!Objects.equals("net.minecraft.client.main.Main", manifest.resolve(dependencyManager.getGameRepository()).mainClass())) + if (!Objects.equals("net.minecraft.client.main.Main", dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass())) throw new UnsupportedInstallationException(FABRIC_NOT_COMPATIBLE_WITH_FORGE); } 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 aecb38a4333..7ad8706099c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceManifest.java @@ -426,14 +426,6 @@ public AssetIndexInfo getAssetIndex() { } } - /// Resolves this manifest through the repository. - /// - /// @param repository the repository that provides parent manifests - /// @return the resolved manifest - public GameInstanceManifest resolve(GameRepository repository) throws NoSuchGameInstanceException { - return repository.resolve(this).launchManifest(); - } - public GameInstanceManifest withId(GameInstanceID id) { Objects.requireNonNull(id); From 31aaa25735e36ee935fce45e5421d137bfabdc07 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 8 Aug 2026 21:51:03 +0800 Subject: [PATCH 146/199] refactor(DefaultGameRepository): remove redundant instance loading logic --- .../jackhuang/hmcl/game/DefaultGameRepository.java | 14 -------------- 1 file changed, 14 deletions(-) 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 410104abc1c..23290504497 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -239,20 +239,6 @@ public void refresh() { } } - Map loadedInstances = new TreeMap<>(); - for (DefaultGameInstance instance : newSnapshot.values()) { - try { - GameInstanceManifest resolved = instance.getResolvedManifest().launchManifest(); - if (CompatibilityRule.appliesToCurrentEnvironment(resolved.compatibilityRules())) { - loadedInstances.put(instance.getId(), instance); - } - } catch (NoSuchGameInstanceException e) { - LOG.warning("Ignoring instance " + instance.getId() + " because it inherits from a nonexistent instance."); - } - } - - newSnapshot.clear(); - newSnapshot.putAll(loadedInstances); // Mark loaded before publishing so snapshot listeners observe a ready repository. loaded = true; publishSnapshot(newSnapshot); From 587f26c103eae1c5fe7edfb13e840b2158e9eaf3 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 19:24:59 +0800 Subject: [PATCH 147/199] refactor(ModListPage, NeoForgeInstallTask): simplify game version retrieval logic --- .../main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java | 3 +-- .../jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java index f95f745c893..07e5d50427d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ModListPage.java @@ -100,8 +100,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { return; } - GameInstanceManifest resolved = gameInstance.getResolvedManifest().standaloneManifest(); - this.gameVersion = gameInstance.getRepository().getGameVersion(resolved).orElse(null); + this.gameVersion = gameInstance.getVersion().toString(); loadMods(gameInstance.getModManager()); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java index eb3ac1a6c2a..b978ff3d2d1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java @@ -102,7 +102,7 @@ public void execute() throws Exception { public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest version, Path installer) throws IOException, VersionMismatchException { Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(version); - if (!gameVersion.isPresent()) throw new IOException(); + if (gameVersion.isEmpty()) throw new IOException(); try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); From 4d59d32cea4803efd9d47f858ec9296a2c6040dd Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 19:39:45 +0800 Subject: [PATCH 148/199] refactor(DefaultDependencyManager, UpdateInstallerWizardProvider): simplify library installation logic and improve manifest handling --- .../hmcl/ui/download/UpdateInstallerWizardProvider.java | 2 +- .../hmcl/download/DefaultDependencyManager.java | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) 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 e1c63f931dc..31fc0094a65 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 @@ -77,7 +77,7 @@ public Object finish(SettingsMap settings) { var hints = new ArrayList(); for (Object value : settings.asStringMap().values()) { if (value instanceof RemoteVersion remoteVersion) { - ret = ret.thenComposeAsync(version -> dependencyManager.installLibraryAsync(version, remoteVersion)); + ret = ret.thenComposeAsync(manifest -> dependencyManager.installLibraryAsync(manifest, remoteVersion)); hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()))); if ("game".equals(remoteVersion.getLibraryId())) { hints.add(new Task.StagesHint("hmcl.install.libraries")); 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 7973f2dd994..3f6c2a3a8b9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -138,7 +138,7 @@ public Task checkPatchCompletionAsync( continue; if (type == GameComponentType.OPTIFINE) { - String optifinePatchVersion = Optional.ofNullable(instance.getComponentVersion(type)) .map(optifineVersion -> { + 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; }) @@ -256,17 +256,14 @@ public UnsupportedLibraryInstallerException() { /// Creates a task that removes a loader's libraries and patch from a manifest. /// - /// @param manifest the unresolved instance manifest + /// @param manifest the unresolved instance manifest /// @param componentType the patch identifier, such as `forge`, `optifine`, or `fabric` /// @return the task producing the updated independent manifest public Task removeLibraryAsync(GameInstanceManifest manifest, GameComponentType componentType) { // Library removal operates on a standalone manifest so inherited launch metadata is retained. return Task.supplyAsync(() -> { GameInstanceManifest standaloneManifest = repository.resolve(manifest).standaloneManifest(); - GameVersionNumber gameVersion = repository.getGameVersion(standaloneManifest) - .map(GameVersionNumber::asGameVersion) - .orElse(null); - return GameComponentAnalyzer.analyze(standaloneManifest, gameVersion).removeLibrary(componentType); + return GameComponentAnalyzer.analyze(standaloneManifest, null).removeLibrary(componentType); }); } From ef0300ca1efdd8430a0de4a8d9857fba126a7296 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 19:43:14 +0800 Subject: [PATCH 149/199] refactor(DefaultGameRepositorySnapshot): update method signatures to use List instead of Collection for better type specificity --- .../jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 6935890c620..7f4aad21e1e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -269,7 +269,7 @@ private GameInstanceManifest.Resolved resolve( resolve(parentInstance.getManifest(), resolvedSoFar); launchManifest = manifest.merge(parentResolved.launchManifest()); standaloneManifest = addPatches( - addPatches(parentResolved.standaloneManifest(), Collections.singleton(manifest.toPatch())), + addPatches(parentResolved.standaloneManifest(), List.of(manifest.toPatch())), manifest.patches()); } } @@ -355,7 +355,7 @@ private static GameInstanceManifest uniqueLibraries(GameInstanceManifest manifes : manifest.withLibraries(libraries); } - private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @Nullable Collection additional) { + private static GameInstanceManifest addPatches(GameInstanceManifest manifest, @Nullable List additional) { if (additional == null || additional.isEmpty()) { return manifest; } From b5c3e6143b965723553cb13d7e716796c5d9a289 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 19:43:57 +0800 Subject: [PATCH 150/199] refactor(DefaultGameRepositorySnapshot): remove unused clear method to simplify code --- .../jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java | 6 ------ 1 file changed, 6 deletions(-) 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 7f4aad21e1e..fb0e762ec5f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -188,12 +188,6 @@ void remove(GameInstanceID id) { instances.remove(id); } - /// Removes all instances from this unsealed snapshot. - void clear() { - checkMutable(); - instances.clear(); - } - /// Creates an unsealed copy of this snapshot with instances rebound to the copy. /// /// @return a mutable snapshot ready for further edits before publish From 69d4c783ebf4efbca2df782dd5c39afd637b7d1e Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 19:48:10 +0800 Subject: [PATCH 151/199] refactor(DependencyManager, GameBuilder, InstallTasks): rename library methods to component methods for consistency --- .../hmcl/game/HMCLModpackInstallTask.java | 2 +- .../download/UpdateInstallerWizardProvider.java | 4 ++-- .../hmcl/ui/instances/InstallerListPage.java | 6 +++--- .../hmcl/download/DefaultDependencyManager.java | 16 ++++++++-------- .../hmcl/download/DefaultGameBuilder.java | 4 ++-- .../hmcl/download/DependencyManager.java | 6 +++--- .../hmcl/download/forge/ForgeNewInstallTask.java | 2 +- .../hmcl/download/forge/ForgeOldInstallTask.java | 2 +- .../neoforge/NeoForgeOldInstallTask.java | 2 +- 9 files changed, 22 insertions(+), 22 deletions(-) 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 a7a3a03a5fe..092af83f4f3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -96,7 +96,7 @@ public void execute() throws Exception { for (GameComponentAnalyzer.Mark mark : analyzer) { if (mark.componentType() == GameComponentType.GAME) continue; - libraryTask = libraryTask.thenComposeAsync(version -> dependency.installLibraryAsync(modpack.getGameVersion(), version, mark.componentType().getPatchId(), mark.version())); + libraryTask = libraryTask.thenComposeAsync(version -> dependency.installComponentAsync(modpack.getGameVersion(), version, mark.componentType().getPatchId(), mark.version())); } dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync)); 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 31fc0094a65..79658b8403c 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 @@ -77,14 +77,14 @@ public Object finish(SettingsMap settings) { var hints = new ArrayList(); for (Object value : settings.asStringMap().values()) { if (value instanceof RemoteVersion remoteVersion) { - ret = ret.thenComposeAsync(manifest -> dependencyManager.installLibraryAsync(manifest, remoteVersion)); + ret = ret.thenComposeAsync(manifest -> dependencyManager.installComponentAsync(manifest, remoteVersion)); hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()))); if ("game".equals(remoteVersion.getLibraryId())) { hints.add(new Task.StagesHint("hmcl.install.libraries")); hints.add(new Task.StagesHint("hmcl.install.assets")); } } else if (value instanceof RemoveVersionAction removeVersionAction) { - ret = ret.thenComposeAsync(version -> dependencyManager.removeLibraryAsync(version, removeVersionAction.componentType)); + ret = ret.thenComposeAsync(manifest -> dependencyManager.removeComponentAsync(manifest, removeVersionAction.componentType)); } } 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 5a0ac0a90d5..cfd1f151604 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 @@ -105,7 +105,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType().getPatchId(), libraryVersion)); }); - component.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), component.getComponentType()) + component.setOnRemove(() -> repository.getDependency().removeComponentAsync(gameInstance.getManifest(), component.getComponentType()) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) @@ -120,7 +120,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM); installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false)); - installerItem.setOnRemove(() -> repository.getDependency().removeLibraryAsync(gameInstance.getManifest(), mark.componentType()) + installerItem.setOnRemove(() -> repository.getDependency().removeComponentAsync(gameInstance.getManifest(), mark.componentType()) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) @@ -149,7 +149,7 @@ private void doInstallOffline(Path file) { } HMCLGameRepository repository = gameInstance.getRepository(); - Task task = repository.getDependency().installLibraryAsync(gameInstance.getManifest(), file) + Task task = repository.getDependency().installComponentAsync(gameInstance.getManifest(), file) .thenComposeAsync(repository::saveAsync) .thenComposeAsync(repository.refreshAsync()); task.setName(i18n("install.installer.install_offline")); 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 3f6c2a3a8b9..8a96e650d55 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -114,7 +114,7 @@ public Task checkGameCompletionAsync( } @Override - public Task checkLibraryCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck) { + public Task checkComponentCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck) { return new GameLibrariesTask(this, manifest, integrityCheck, manifest.getLibraries()); } @@ -156,7 +156,7 @@ public Task checkPatchCompletionAsync( if (needsReInstallation) { Library installer = new Library(new Artifact("optifine", "OptiFine", gameVersion + "_" + optifinePatchVersion, "installer")); if (GameLibrariesTask.shouldDownloadLibrary(repository, manifest, installer, integrityCheck)) { - tasks.add(installLibraryAsync(gameVersion, original, "optifine", optifinePatchVersion)); + tasks.add(installComponentAsync(gameVersion, original, "optifine", optifinePatchVersion)); } else { tasks.add(OptiFineInstallTask.install(this, original, repository.getLayout().getLibraryFile(manifest.id(), installer))); } @@ -169,19 +169,19 @@ public Task checkPatchCompletionAsync( } @Override - public Task installLibraryAsync(String gameVersion, GameInstanceManifest baseVersion, String libraryId, String libraryVersion) { + public Task installComponentAsync(String gameVersion, GameInstanceManifest baseVersion, String libraryId, String libraryVersion) { VersionList versionList = getVersionList(libraryId); return versionList.loadAsync(gameVersion) - .thenComposeAsync(() -> installLibraryAsync(baseVersion, versionList.getVersion(gameVersion, libraryVersion) + .thenComposeAsync(() -> installComponentAsync(baseVersion, versionList.getVersion(gameVersion, libraryVersion) .orElseThrow(() -> new IOException("Remote library " + libraryId + " has no version " + libraryVersion)))) .withStage(String.format("hmcl.install.%s:%s", libraryId, libraryVersion)); } @Override - public Task installLibraryAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion) { + public Task installComponentAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion) { AtomicReference removedLibraryManifest = new AtomicReference<>(); - return removeLibraryAsync(baseVersion, libraryVersion.getComponentType()) + return removeComponentAsync(baseVersion, libraryVersion.getComponentType()) .thenComposeAsync(manifest -> { removedLibraryManifest.set(manifest); return libraryVersion.getInstallTask(this, manifest, modsDirectoryFor(manifest)); @@ -218,7 +218,7 @@ private Path modsDirectoryFor(GameInstanceManifest manifest) { /// @param oldVersion the manifest to which the installed patch will be added /// @param installer the local installer jar /// @return the task producing the updated manifest - public Task installLibraryAsync(GameInstanceManifest oldVersion, Path installer) { + public Task installComponentAsync(GameInstanceManifest oldVersion, Path installer) { return Task .composeAsync(() -> { try { @@ -259,7 +259,7 @@ public UnsupportedLibraryInstallerException() { /// @param manifest the unresolved instance manifest /// @param componentType the patch identifier, such as `forge`, `optifine`, or `fabric` /// @return the task producing the updated independent manifest - public Task removeLibraryAsync(GameInstanceManifest manifest, GameComponentType componentType) { + public Task removeComponentAsync(GameInstanceManifest manifest, GameComponentType componentType) { // Library removal operates on a standalone manifest so inherited launch metadata is retained. return Task.supplyAsync(() -> { GameInstanceManifest standaloneManifest = repository.resolve(manifest).standaloneManifest(); 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 31397baf97a..2282ba53f47 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -59,7 +59,7 @@ public Task buildAsync() { } for (RemoteVersion remoteVersion : remoteVersions) { - libraryTask = libraryTask.thenComposeAsync(version -> dependencyManager.installLibraryAsync(version, remoteVersion)); + libraryTask = libraryTask.thenComposeAsync(version -> dependencyManager.installComponentAsync(version, remoteVersion)); hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()))); } @@ -70,6 +70,6 @@ public Task buildAsync() { } private ExceptionalFunction, ?> libraryTaskHelper(String gameVersion, String libraryId, String libraryVersion) { - return version -> dependencyManager.installLibraryAsync(gameVersion, version, libraryId, libraryVersion); + return version -> dependencyManager.installComponentAsync(gameVersion, version, libraryId, libraryVersion); } } 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 73b75c8df93..bd3e93c1176 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java @@ -54,7 +54,7 @@ public interface DependencyManager { /// @param manifest the manifest whose libraries are checked /// @param integrityCheck whether existing libraries must be verified /// @return the library-completion task - Task checkLibraryCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck); + Task checkComponentCompletionAsync(GameInstanceManifest manifest, boolean integrityCheck); /// Creates a task that repairs installable patches required by an instance. /// @@ -81,14 +81,14 @@ public interface DependencyManager { /// @param libraryId the registered library type, such as `forge` or `optifine` /// @param libraryVersion the library version to install /// @return the installation task - Task installLibraryAsync(String gameVersion, GameInstanceManifest baseVersion, String libraryId, String libraryVersion); + Task installComponentAsync(String gameVersion, GameInstanceManifest baseVersion, String libraryId, String libraryVersion); /// Creates a task that installs a remote loader or patch into a base manifest. /// /// @param baseVersion the base manifest /// @param libraryVersion the remote library version to install /// @return the installation task - Task installLibraryAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion); + Task installComponentAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion); /// Returns a registered remote-version list. /// diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java index 8ca59578786..bce4af7d909 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java @@ -417,7 +417,7 @@ public void execute() throws Exception { dependencies.add( processorsTask.thenComposeAsync( - dependencyManager.checkLibraryCompletionAsync(forgeVersion, true))); + dependencyManager.checkComponentCompletionAsync(forgeVersion, true))); setResult(GameInstancePatch.fromManifest( forgeVersion, diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java index 703766b3720..28b15140ed6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeOldInstallTask.java @@ -85,7 +85,7 @@ public void execute() throws Exception { GameComponentType.FORGE.getPatchId(), selfVersion, GameInstancePatch.PRIORITY_LOADER)); - dependencies.add(dependencyManager.checkLibraryCompletionAsync(installProfile.getVersionInfo(), true)); + dependencies.add(dependencyManager.checkComponentCompletionAsync(installProfile.getVersionInfo(), true)); } catch (ZipException ex) { throw new ArtifactMalformedException("Malformed forge installer file", ex); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java index 447f3ec02e7..3e30f87b84c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java @@ -401,7 +401,7 @@ public void execute() throws Exception { dependencies.add( processorsTask.thenComposeAsync( - dependencyManager.checkLibraryCompletionAsync(neoForgeVersion, true))); + dependencyManager.checkComponentCompletionAsync(neoForgeVersion, true))); setResult(GameInstancePatch.fromManifest( neoForgeVersion, From cdb48477a552b970b96e671134f23d11f967d95e Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 19:51:25 +0800 Subject: [PATCH 152/199] refactor(DefaultDependencyManager, HMCLModpackInstallTask): rename baseVersion to baseManifest for clarity and consistency --- .../jackhuang/hmcl/game/HMCLModpackInstallTask.java | 3 ++- .../hmcl/download/DefaultDependencyManager.java | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) 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 092af83f4f3..201ef0f7296 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -96,7 +96,8 @@ public void execute() throws Exception { for (GameComponentAnalyzer.Mark mark : analyzer) { if (mark.componentType() == GameComponentType.GAME) continue; - libraryTask = libraryTask.thenComposeAsync(version -> dependency.installComponentAsync(modpack.getGameVersion(), version, mark.componentType().getPatchId(), mark.version())); + libraryTask = libraryTask + .thenComposeAsync(manifest -> dependency.installComponentAsync(modpack.getGameVersion(), manifest, mark.componentType().getPatchId(), mark.version())); } dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync)); 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 8a96e650d55..db547d32554 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -169,28 +169,28 @@ public Task checkPatchCompletionAsync( } @Override - public Task installComponentAsync(String gameVersion, GameInstanceManifest baseVersion, String libraryId, String libraryVersion) { + public Task installComponentAsync(String gameVersion, GameInstanceManifest baseManifest, String libraryId, String libraryVersion) { VersionList versionList = getVersionList(libraryId); return versionList.loadAsync(gameVersion) - .thenComposeAsync(() -> installComponentAsync(baseVersion, versionList.getVersion(gameVersion, libraryVersion) + .thenComposeAsync(() -> installComponentAsync(baseManifest, versionList.getVersion(gameVersion, libraryVersion) .orElseThrow(() -> new IOException("Remote library " + libraryId + " has no version " + libraryVersion)))) .withStage(String.format("hmcl.install.%s:%s", libraryId, libraryVersion)); } @Override public Task installComponentAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion) { - AtomicReference removedLibraryManifest = new AtomicReference<>(); + AtomicReference removedComponentManifest = new AtomicReference<>(); return removeComponentAsync(baseVersion, libraryVersion.getComponentType()) .thenComposeAsync(manifest -> { - removedLibraryManifest.set(manifest); + removedComponentManifest.set(manifest); return libraryVersion.getInstallTask(this, manifest, modsDirectoryFor(manifest)); }) .thenApplyAsync(patch -> { if (patch == null) { - return removedLibraryManifest.get(); + return removedComponentManifest.get(); } else { - return removedLibraryManifest.get().addPatch(patch); + return removedComponentManifest.get().addPatch(patch); } }) .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getLibraryId(), libraryVersion.getSelfVersion())); From f41e4986ca9e0dad937adfbab1aa4a310b8816f0 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 21:01:25 +0800 Subject: [PATCH 153/199] refactor(DefaultDependencyManager, GameBuilder, DependencyManager): update installComponentAsync methods to use GameInstance for better clarity and consistency --- .../hmcl/game/HMCLModpackInstallTask.java | 19 +- .../UpdateInstallerWizardProvider.java | 10 +- .../hmcl/ui/instances/InstallerListPage.java | 6 +- .../download/DefaultDependencyManager.java | 220 ++++++++++++++---- .../hmcl/download/DefaultGameBuilder.java | 67 ++++-- .../hmcl/download/DependencyManager.java | 12 +- 6 files changed, 260 insertions(+), 74 deletions(-) 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 201ef0f7296..8dd7cd6c601 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -90,14 +90,27 @@ 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); GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null); - Task libraryTask = Task.supplyAsync(() -> originalManifest); + DefaultGameInstance instance = repository.getSnapshot().findInstance(instanceId); + if (instance == null) { + throw new IllegalStateException("Instance " + instanceId + " was not registered by the game builder"); + } + + Task libraryTask = Task.completed(originalManifest); // reinstall libraries // libraries of Forge and OptiFine should be obtained by installation. for (GameComponentAnalyzer.Mark mark : analyzer) { if (mark.componentType() == GameComponentType.GAME) continue; - libraryTask = libraryTask - .thenComposeAsync(manifest -> dependency.installComponentAsync(modpack.getGameVersion(), manifest, mark.componentType().getPatchId(), mark.version())); + String componentVersion = mark.version(); + if (componentVersion == null) { + continue; + } + libraryTask = libraryTask.thenComposeAsync(manifest -> dependency.installComponentAsync( + instance, + manifest, + modpack.getGameVersion(), + mark.componentType().getPatchId(), + componentVersion)); } dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync)); 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 79658b8403c..8cbc8c0dc0f 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 @@ -71,20 +71,22 @@ public Object finish(SettingsMap settings) { settings.put("success_message", i18n("install.success")); settings.put(FailureCallback.KEY, (settings1, exception, next) -> alertFailureMessage(exception, next)); - // We remove library but not save it, - // so if installation failed will not break down current version. + // Edit a working manifest in memory against the registered instance; save only on success + // so a failed install does not leave a half-written instance json. Task ret = Task.supplyAsync(gameInstance::getManifest); var hints = new ArrayList(); for (Object value : settings.asStringMap().values()) { if (value instanceof RemoteVersion remoteVersion) { - ret = ret.thenComposeAsync(manifest -> dependencyManager.installComponentAsync(manifest, remoteVersion)); + ret = ret.thenComposeAsync(manifest -> + dependencyManager.installComponentAsync(gameInstance, manifest, remoteVersion)); hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()))); if ("game".equals(remoteVersion.getLibraryId())) { hints.add(new Task.StagesHint("hmcl.install.libraries")); hints.add(new Task.StagesHint("hmcl.install.assets")); } } else if (value instanceof RemoveVersionAction removeVersionAction) { - ret = ret.thenComposeAsync(manifest -> dependencyManager.removeComponentAsync(manifest, removeVersionAction.componentType)); + ret = ret.thenComposeAsync(manifest -> + dependencyManager.removeComponentAsync(gameInstance, manifest, removeVersionAction.componentType)); } } 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 cfd1f151604..4ef10de7761 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 @@ -105,7 +105,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType().getPatchId(), libraryVersion)); }); - component.setOnRemove(() -> repository.getDependency().removeComponentAsync(gameInstance.getManifest(), component.getComponentType()) + component.setOnRemove(() -> repository.getDependency().removeComponentAsync(gameInstance, component.getComponentType()) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) @@ -120,7 +120,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM); installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false)); - installerItem.setOnRemove(() -> repository.getDependency().removeComponentAsync(gameInstance.getManifest(), mark.componentType()) + installerItem.setOnRemove(() -> repository.getDependency().removeComponentAsync(gameInstance, mark.componentType()) .thenComposeAsync(repository::saveAsync) .withComposeAsync(repository.refreshAsync()) .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) @@ -149,7 +149,7 @@ private void doInstallOffline(Path file) { } HMCLGameRepository repository = gameInstance.getRepository(); - Task task = repository.getDependency().installComponentAsync(gameInstance.getManifest(), file) + Task task = repository.getDependency().installComponentAsync(gameInstance, file) .thenComposeAsync(repository::saveAsync) .thenComposeAsync(repository.refreshAsync()); task.setName(i18n("install.installer.install_offline")); 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 db547d32554..889c64b4ffe 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -156,7 +157,7 @@ public Task checkPatchCompletionAsync( if (needsReInstallation) { Library installer = new Library(new Artifact("optifine", "OptiFine", gameVersion + "_" + optifinePatchVersion, "installer")); if (GameLibrariesTask.shouldDownloadLibrary(repository, manifest, installer, integrityCheck)) { - tasks.add(installComponentAsync(gameVersion, original, "optifine", optifinePatchVersion)); + tasks.add(installComponentAsync(instance, original, gameVersion, "optifine", optifinePatchVersion)); } else { tasks.add(OptiFineInstallTask.install(this, original, repository.getLayout().getLibraryFile(manifest.id(), installer))); } @@ -168,23 +169,40 @@ public Task checkPatchCompletionAsync( }); } - @Override - public Task installComponentAsync(String gameVersion, GameInstanceManifest baseManifest, String libraryId, String libraryVersion) { - VersionList versionList = getVersionList(libraryId); - return versionList.loadAsync(gameVersion) - .thenComposeAsync(() -> installComponentAsync(baseManifest, versionList.getVersion(gameVersion, libraryVersion) - .orElseThrow(() -> new IOException("Remote library " + libraryId + " has no version " + libraryVersion)))) - .withStage(String.format("hmcl.install.%s:%s", libraryId, libraryVersion)); + /// Installs a component into a registered instance using its stored manifest as the base. + /// + /// @param instance the target instance; must belong to this manager's repository + /// @param libraryVersion the remote component to install + /// @return the task producing the updated standalone manifest (not yet saved) + public Task installComponentAsync(GameInstance instance, RemoteVersion libraryVersion) { + validateGameInstance(instance); + return installComponentAsync(instance, instance.getManifest(), libraryVersion); } - @Override - public Task installComponentAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion) { + /// 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); + requireSameInstance(instance, baseManifest); + AtomicReference removedComponentManifest = new AtomicReference<>(); + Path modsDirectory = instance.getModsDirectory(); - return removeComponentAsync(baseVersion, libraryVersion.getComponentType()) + return removeComponentAsync(instance, baseManifest, libraryVersion.getComponentType()) .thenComposeAsync(manifest -> { removedComponentManifest.set(manifest); - return libraryVersion.getInstallTask(this, manifest, modsDirectoryFor(manifest)); + return libraryVersion.getInstallTask(this, manifest, modsDirectory); }) .thenApplyAsync(patch -> { if (patch == null) { @@ -196,54 +214,100 @@ public Task installComponentAsync(GameInstanceManifest bas .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getLibraryId(), libraryVersion.getSelfVersion())); } - /// Resolves the mods directory for the instance identified by `manifest`. + /// Resolves a remote component by id/version and installs it into the working manifest. /// - /// Prefer the registered [GameInstance] when present so isolation/run-directory policy is - /// honored. Falls back to the shared repository base directory when the instance is not yet - /// indexed (should be rare after [org.jackhuang.hmcl.download.DefaultGameBuilder] registers a - /// placeholder instance). + /// @param 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 libraryId the component list id, such as `game` or `forge` + /// @param libraryVersion the component version id + /// @return the installation task + public Task installComponentAsync( + GameInstance instance, + GameInstanceManifest baseManifest, + String gameVersion, + String libraryId, + String libraryVersion) { + validateGameInstance(instance); + requireSameInstance(instance, baseManifest); + + VersionList versionList = getVersionList(libraryId); + return versionList.loadAsync(gameVersion) + .thenComposeAsync(() -> installComponentAsync( + instance, + baseManifest, + versionList.getVersion(gameVersion, libraryVersion) + .orElseThrow(() -> new IOException( + "Remote library " + libraryId + " has no version " + libraryVersion)))) + .withStage(String.format("hmcl.install.%s:%s", libraryId, libraryVersion)); + } + + @Override + public Task installComponentAsync( + String gameVersion, + GameInstanceManifest baseManifest, + String libraryId, + String libraryVersion) { + DefaultGameInstance instance = requireRegisteredInstance(baseManifest.id()); + return installComponentAsync(instance, baseManifest, gameVersion, libraryId, libraryVersion); + } + + @Override + public Task installComponentAsync( + GameInstanceManifest baseVersion, + RemoteVersion libraryVersion) { + DefaultGameInstance instance = requireRegisteredInstance(baseVersion.id()); + return installComponentAsync(instance, baseVersion, libraryVersion); + } + + /// Installs a component from a local installer jar into a registered instance. /// - /// @param manifest the install target manifest - /// @return the mods directory path - private Path modsDirectoryFor(GameInstanceManifest manifest) { - DefaultGameInstance instance = repository.getSnapshot().findInstance(manifest.id()); - if (instance != null) { - return instance.getModsDirectory(); - } - return repository.getBaseDirectory().resolve("mods"); + /// @param instance the target instance + /// @param installer the local installer jar + /// @return the task producing the updated manifest (not yet saved) + public Task installComponentAsync(GameInstance instance, Path installer) { + validateGameInstance(instance); + return installComponentAsync(instance, instance.getManifest(), installer); } - /// Creates a task that detects and runs a supported local library installer. + /// Installs a component from a local installer jar into a working manifest. /// - /// @param oldVersion the manifest to which the installed patch will be added - /// @param installer the local installer jar - /// @return the task producing the updated manifest - public Task installComponentAsync(GameInstanceManifest oldVersion, Path installer) { + /// @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); + requireSameInstance(instance, baseManifest); + return Task .composeAsync(() -> { try { - return CleanroomInstallTask.install(this, oldVersion, installer); + return CleanroomInstallTask.install(this, baseManifest, installer); } catch (IOException ignore) { } try { - return NeoForgeInstallTask.install(this, oldVersion, installer); + return NeoForgeInstallTask.install(this, baseManifest, installer); } catch (IOException ignore) { } try { - return ForgeInstallTask.install(this, oldVersion, installer); + return ForgeInstallTask.install(this, baseManifest, installer); } catch (IOException ignore) { } try { - return OptiFineInstallTask.install(this, oldVersion, installer); + return OptiFineInstallTask.install(this, baseManifest, installer); } catch (IOException ignore) { } throw new UnsupportedLibraryInstallerException(); }) - .thenApplyAsync(patch -> patch == null ? oldVersion : oldVersion.addPatch(patch)); + .thenApplyAsync(patch -> patch == null ? baseManifest : baseManifest.addPatch(patch)); } /// Indicates that a local library installer is not recognized by any supported installer. @@ -254,17 +318,87 @@ public UnsupportedLibraryInstallerException() { } } - /// Creates a task that removes a loader's libraries and patch from a manifest. + /// Removes a component from a registered instance using its current stored manifest. + /// + /// @param instance the target instance + /// @param componentType the component to remove + /// @return the task producing the updated standalone manifest (not yet saved) + public Task removeComponentAsync(GameInstance instance, GameComponentType componentType) { + validateGameInstance(instance); + return removeComponentAsync(instance, instance.getManifest(), componentType); + } + + /// Removes a component from a working manifest bound to a registered instance. /// - /// @param manifest the unresolved instance manifest - /// @param componentType the patch identifier, such as `forge`, `optifine`, or `fabric` - /// @return the task producing the updated independent manifest - public Task removeComponentAsync(GameInstanceManifest manifest, GameComponentType componentType) { - // Library removal operates on a standalone manifest so inherited launch metadata is retained. + /// Edits a standalone view so inherited launch metadata is retained. When `workingManifest` + /// matches the instance's stored manifest, the instance's resolved standalone view is used; + /// otherwise an independent working draft is used as-is (or resolved if it still inherits). + /// + /// @param instance the registered instance (version + identity) + /// @param workingManifest the draft being edited + /// @param componentType the component to remove + /// @return the task producing the updated standalone manifest (not yet saved) + public Task removeComponentAsync( + GameInstance instance, + GameInstanceManifest workingManifest, + GameComponentType componentType) { + validateGameInstance(instance); + requireSameInstance(instance, workingManifest); + return Task.supplyAsync(() -> { - GameInstanceManifest standaloneManifest = repository.resolve(manifest).standaloneManifest(); - return GameComponentAnalyzer.analyze(standaloneManifest, null).removeLibrary(componentType); + GameInstanceManifest standalone = standaloneEditBase(instance, workingManifest); + return GameComponentAnalyzer.analyze(standalone, gameVersionOf(instance)).removeLibrary(componentType); }); } + /// Removes a component from a manifest of a registered instance. + /// + /// Prefer [#removeComponentAsync(GameInstance, GameComponentType)] when the caller already holds + /// the instance. + /// + /// @param manifest the working or stored manifest + /// @param componentType the component to remove + /// @return the task producing the updated standalone manifest (not yet saved) + public Task removeComponentAsync( + GameInstanceManifest manifest, + GameComponentType componentType) { + DefaultGameInstance instance = requireRegisteredInstance(manifest.id()); + return removeComponentAsync(instance, manifest, componentType); + } + + /// Returns the standalone manifest to edit for component remove/install. + private GameInstanceManifest standaloneEditBase(GameInstance instance, GameInstanceManifest workingManifest) { + if (workingManifest.equals(instance.getManifest())) { + return instance.getResolvedManifest().standaloneManifest(); + } + if (workingManifest.inheritsFrom() == null) { + return workingManifest; + } + return repository.resolve(workingManifest).standaloneManifest(); + } + + /// Returns the detected Minecraft version for analyze, or `null` when unknown. + private static @Nullable GameVersionNumber gameVersionOf(GameInstance instance) { + GameVersionNumber version = instance.getVersion(); + return version.equals(GameVersionNumber.unknown()) ? null : version; + } + + /// Requires a registered instance with the given id in this repository. + private DefaultGameInstance requireRegisteredInstance(GameInstanceID instanceId) { + DefaultGameInstance instance = repository.getSnapshot().findInstance(instanceId); + if (instance == null) { + throw new IllegalStateException("No registered instance for " + instanceId + + "; save a placeholder instance before installing components"); + } + return instance; + } + + /// Ensures the working manifest refers to the same instance id. + private static void requireSameInstance(GameInstance instance, GameInstanceManifest manifest) { + if (!instance.getId().equals(manifest.id())) { + throw new IllegalArgumentException("Working manifest id " + manifest.id() + + " does not match instance " + instance.getId()); + } + } + } 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 2282ba53f47..27e53e5b527 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -17,14 +17,19 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.function.ExceptionalFunction; import java.util.ArrayList; import java.util.Map; +import java.util.Objects; /** + * Builds a new game instance by first saving a placeholder instance, then installing components + * against that registered instance. * * @author huangyuhui */ @@ -42,34 +47,62 @@ public DefaultDependencyManager getDependencyManager() { @Override public Task buildAsync() { + Objects.requireNonNull(name, "GameBuilder.name must be set"); var hints = new ArrayList(); - // Register a placeholder instance first so install tasks can resolve run/mods directories - // through GameInstance instead of repository-level path helpers. - Task libraryTask = dependencyManager.getGameRepository() - .saveAsync(new GameInstanceManifest(name)); - libraryTask = libraryTask.thenComposeAsync(libraryTaskHelper(gameVersion, "game", gameVersion)); hints.add(new Task.StagesHint("hmcl.install.game:" + gameVersion)); hints.add(new Task.StagesHint("hmcl.install.libraries")); hints.add(new Task.StagesHint("hmcl.install.assets")); - for (Map.Entry entry : toolVersions.entrySet()) { - libraryTask = libraryTask.thenComposeAsync(libraryTaskHelper(gameVersion, entry.getKey(), entry.getValue())); - hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", entry.getKey(), entry.getValue()))); + hints.add(new Task.StagesHint( + String.format("hmcl.install.%s:%s", entry.getKey(), entry.getValue()))); } - for (RemoteVersion remoteVersion : remoteVersions) { - libraryTask = libraryTask.thenComposeAsync(version -> dependencyManager.installComponentAsync(version, remoteVersion)); - hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()))); + hints.add(new Task.StagesHint(String.format( + "hmcl.install.%s:%s", + remoteVersion.getLibraryId(), + remoteVersion.getSelfVersion()))); } - return libraryTask.thenComposeAsync(dependencyManager.getGameRepository()::saveAsync).whenComplete(exception -> { - if (exception != null) - dependencyManager.getGameRepository().removeInstanceFromDisk(name); - }).withStagesHints(hints); + // Register a placeholder instance first so every install step has a real GameInstance + // (paths, snapshot identity). On failure the whole instance directory is removed. + return dependencyManager.getGameRepository() + .saveAsync(new GameInstanceManifest(name)) + .thenComposeAsync(placeholder -> { + DefaultGameInstance instance = Objects.requireNonNull( + dependencyManager.getGameRepository().getSnapshot().findInstance(name), + "placeholder instance missing after save: " + name); + + Task libraryTask = Task.completed(placeholder); + libraryTask = libraryTask.thenComposeAsync( + libraryTaskHelper(instance, gameVersion, "game", gameVersion)); + + for (Map.Entry entry : toolVersions.entrySet()) { + libraryTask = libraryTask.thenComposeAsync( + libraryTaskHelper(instance, gameVersion, entry.getKey(), entry.getValue())); + } + + for (RemoteVersion remoteVersion : remoteVersions) { + libraryTask = libraryTask.thenComposeAsync( + working -> dependencyManager.installComponentAsync(instance, working, remoteVersion)); + } + + return libraryTask.thenComposeAsync(dependencyManager.getGameRepository()::saveAsync); + }) + .whenComplete(exception -> { + if (exception != null) { + dependencyManager.getGameRepository().removeInstanceFromDisk(name); + } + }) + .withStagesHints(hints); } - private ExceptionalFunction, ?> libraryTaskHelper(String gameVersion, String libraryId, String libraryVersion) { - return version -> dependencyManager.installComponentAsync(gameVersion, version, libraryId, libraryVersion); + private ExceptionalFunction, ?> libraryTaskHelper( + GameInstance instance, + String gameVersion, + String libraryId, + String libraryVersion) { + return working -> dependencyManager.installComponentAsync( + instance, working, gameVersion, libraryId, libraryVersion); } } 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 bd3e93c1176..318c9941fe6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java @@ -74,18 +74,22 @@ public interface DependencyManager { /// @return a new game builder GameBuilder newGameBuilder(); - /// Creates a task that installs a loader or patch into a base manifest. + /// Creates a task that installs a loader or patch into a registered instance's working manifest. + /// + /// The instance must already be saved in [#getGameRepository()] so install tasks can resolve + /// run/mods directories. Prefer instance-bound overloads on concrete managers when available. /// /// @param gameVersion the Minecraft version required by the library - /// @param baseVersion the base manifest + /// @param baseVersion the working manifest for this step (same id as the registered instance) /// @param libraryId the registered library type, such as `forge` or `optifine` /// @param libraryVersion the library version to install /// @return the installation task Task installComponentAsync(String gameVersion, GameInstanceManifest baseVersion, String libraryId, String libraryVersion); - /// Creates a task that installs a remote loader or patch into a base manifest. + /// Creates a task that installs a remote loader or patch into a registered instance's working + /// manifest. /// - /// @param baseVersion the base manifest + /// @param baseVersion the working manifest for this step (same id as the registered instance) /// @param libraryVersion the remote library version to install /// @return the installation task Task installComponentAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion); From 557819515c61d5f15c8cab9ff55e9cfe18545df2 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 21:35:11 +0800 Subject: [PATCH 154/199] refactor(DefaultDependencyManager, DefaultGameBuilder, HMCLModpackInstallTask): simplify instance retrieval and validation logic for clarity and consistency --- .../hmcl/game/HMCLModpackInstallTask.java | 5 +- .../download/DefaultDependencyManager.java | 95 ++++++++----------- .../hmcl/download/DefaultGameBuilder.java | 4 +- 3 files changed, 42 insertions(+), 62 deletions(-) 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 8dd7cd6c601..f85248acb75 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -90,10 +90,7 @@ 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); GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null); - DefaultGameInstance instance = repository.getSnapshot().findInstance(instanceId); - if (instance == null) { - throw new IllegalStateException("Instance " + instanceId + " was not registered by the game builder"); - } + DefaultGameInstance instance = repository.getInstance(instanceId); Task libraryTask = Task.completed(originalManifest); // reinstall libraries 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 889c64b4ffe..586b31741b0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -28,7 +28,6 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; -import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -194,7 +193,9 @@ public Task installComponentAsync( GameInstanceManifest baseManifest, RemoteVersion libraryVersion) { validateGameInstance(instance); - requireSameInstance(instance, baseManifest); + if (!instance.getId().equals(baseManifest.id())) { + throw new IllegalArgumentException("baseManifest id does not match instance"); + } AtomicReference removedComponentManifest = new AtomicReference<>(); Path modsDirectory = instance.getModsDirectory(); @@ -229,7 +230,9 @@ public Task installComponentAsync( String libraryId, String libraryVersion) { validateGameInstance(instance); - requireSameInstance(instance, baseManifest); + if (!instance.getId().equals(baseManifest.id())) { + throw new IllegalArgumentException("baseManifest id does not match instance"); + } VersionList versionList = getVersionList(libraryId); return versionList.loadAsync(gameVersion) @@ -248,16 +251,19 @@ public Task installComponentAsync( GameInstanceManifest baseManifest, String libraryId, String libraryVersion) { - DefaultGameInstance instance = requireRegisteredInstance(baseManifest.id()); - return installComponentAsync(instance, baseManifest, gameVersion, libraryId, libraryVersion); + return installComponentAsync( + repository.getInstance(baseManifest.id()), + baseManifest, + gameVersion, + libraryId, + libraryVersion); } @Override public Task installComponentAsync( GameInstanceManifest baseVersion, RemoteVersion libraryVersion) { - DefaultGameInstance instance = requireRegisteredInstance(baseVersion.id()); - return installComponentAsync(instance, baseVersion, libraryVersion); + return installComponentAsync(repository.getInstance(baseVersion.id()), baseVersion, libraryVersion); } /// Installs a component from a local installer jar into a registered instance. @@ -281,7 +287,9 @@ public Task installComponentAsync( GameInstanceManifest baseManifest, Path installer) { validateGameInstance(instance); - requireSameInstance(instance, baseManifest); + if (!instance.getId().equals(baseManifest.id())) { + throw new IllegalArgumentException("baseManifest id does not match instance"); + } return Task .composeAsync(() -> { @@ -330,11 +338,10 @@ public Task removeComponentAsync(GameInstance instance, Ga /// Removes a component from a working manifest bound to a registered instance. /// - /// Edits a standalone view so inherited launch metadata is retained. When `workingManifest` - /// matches the instance's stored manifest, the instance's resolved standalone view is used; - /// otherwise an independent working draft is used as-is (or resolved if it still inherits). + /// 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 (version + identity) + /// @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) @@ -343,18 +350,32 @@ public Task removeComponentAsync( GameInstanceManifest workingManifest, GameComponentType componentType) { validateGameInstance(instance); - requireSameInstance(instance, workingManifest); + if (!instance.getId().equals(workingManifest.id())) { + throw new IllegalArgumentException("workingManifest id does not match instance"); + } return Task.supplyAsync(() -> { - GameInstanceManifest standalone = standaloneEditBase(instance, workingManifest); - return GameComponentAnalyzer.analyze(standalone, gameVersionOf(instance)).removeLibrary(componentType); + GameInstanceManifest standalone; + if (workingManifest.equals(instance.getManifest())) { + standalone = instance.getResolvedManifest().standaloneManifest(); + } else if (workingManifest.inheritsFrom() == null) { + standalone = workingManifest; + } else { + standalone = repository.resolve(workingManifest).standaloneManifest(); + } + + GameVersionNumber gameVersion = instance.getVersion(); + if (gameVersion.equals(GameVersionNumber.unknown())) { + gameVersion = null; + } + return GameComponentAnalyzer.analyze(standalone, gameVersion).removeLibrary(componentType); }); } - /// Removes a component from a manifest of a registered instance. + /// Removes a component using only a manifest id (looks up the instance in the snapshot). /// - /// Prefer [#removeComponentAsync(GameInstance, GameComponentType)] when the caller already holds - /// the instance. + /// Prefer [#removeComponentAsync(GameInstance, GameComponentType)] when the instance is already + /// available. /// /// @param manifest the working or stored manifest /// @param componentType the component to remove @@ -362,43 +383,7 @@ public Task removeComponentAsync( public Task removeComponentAsync( GameInstanceManifest manifest, GameComponentType componentType) { - DefaultGameInstance instance = requireRegisteredInstance(manifest.id()); - return removeComponentAsync(instance, manifest, componentType); - } - - /// Returns the standalone manifest to edit for component remove/install. - private GameInstanceManifest standaloneEditBase(GameInstance instance, GameInstanceManifest workingManifest) { - if (workingManifest.equals(instance.getManifest())) { - return instance.getResolvedManifest().standaloneManifest(); - } - if (workingManifest.inheritsFrom() == null) { - return workingManifest; - } - return repository.resolve(workingManifest).standaloneManifest(); - } - - /// Returns the detected Minecraft version for analyze, or `null` when unknown. - private static @Nullable GameVersionNumber gameVersionOf(GameInstance instance) { - GameVersionNumber version = instance.getVersion(); - return version.equals(GameVersionNumber.unknown()) ? null : version; - } - - /// Requires a registered instance with the given id in this repository. - private DefaultGameInstance requireRegisteredInstance(GameInstanceID instanceId) { - DefaultGameInstance instance = repository.getSnapshot().findInstance(instanceId); - if (instance == null) { - throw new IllegalStateException("No registered instance for " + instanceId - + "; save a placeholder instance before installing components"); - } - return instance; - } - - /// Ensures the working manifest refers to the same instance id. - private static void requireSameInstance(GameInstance instance, GameInstanceManifest manifest) { - if (!instance.getId().equals(manifest.id())) { - throw new IllegalArgumentException("Working manifest id " + manifest.id() - + " does not match instance " + instance.getId()); - } + return removeComponentAsync(repository.getInstance(manifest.id()), manifest, 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 27e53e5b527..783b27a71d8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -69,9 +69,7 @@ public Task buildAsync() { return dependencyManager.getGameRepository() .saveAsync(new GameInstanceManifest(name)) .thenComposeAsync(placeholder -> { - DefaultGameInstance instance = Objects.requireNonNull( - dependencyManager.getGameRepository().getSnapshot().findInstance(name), - "placeholder instance missing after save: " + name); + DefaultGameInstance instance = dependencyManager.getGameRepository().getInstance(name); Task libraryTask = Task.completed(placeholder); libraryTask = libraryTask.thenComposeAsync( From 2b209424fc6cd0f1a0a3397a40d7c091862f1696 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 9 Aug 2026 21:44:47 +0800 Subject: [PATCH 155/199] refactor(DefaultDependencyManager): streamline component removal logic for improved clarity and consistency --- .../download/DefaultDependencyManager.java | 20 +------------------ 1 file changed, 1 insertion(+), 19 deletions(-) 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 586b31741b0..cb8d71cb6f6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -364,26 +364,8 @@ public Task removeComponentAsync( standalone = repository.resolve(workingManifest).standaloneManifest(); } - GameVersionNumber gameVersion = instance.getVersion(); - if (gameVersion.equals(GameVersionNumber.unknown())) { - gameVersion = null; - } - return GameComponentAnalyzer.analyze(standalone, gameVersion).removeLibrary(componentType); + return GameComponentAnalyzer.analyze(standalone, instance.getVersion()).removeLibrary(componentType); }); } - /// Removes a component using only a manifest id (looks up the instance in the snapshot). - /// - /// Prefer [#removeComponentAsync(GameInstance, GameComponentType)] when the instance is already - /// available. - /// - /// @param manifest the working or stored manifest - /// @param componentType the component to remove - /// @return the task producing the updated standalone manifest (not yet saved) - public Task removeComponentAsync( - GameInstanceManifest manifest, - GameComponentType componentType) { - return removeComponentAsync(repository.getInstance(manifest.id()), manifest, componentType); - } - } From 675b0c05f90f07085c1ccfd04487215e90aac073 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 10 Aug 2026 21:06:37 +0800 Subject: [PATCH 156/199] refactor(CleanroomInstallTask, ForgeInstallTask): rename version variable to patch for improved clarity --- .../hmcl/download/cleanroom/CleanroomInstallTask.java | 4 ++-- .../org/jackhuang/hmcl/download/forge/ForgeInstallTask.java | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) 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 92dd874bf46..48ec0dbb17b 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 @@ -114,10 +114,10 @@ public Collection> getDependencies() { public void execute() throws IOException, VersionMismatchException, UnsupportedInstallationException { if (selfVersion == null) { task = new ForgeNewInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer) - .thenApplyAsync((version) -> version.withId(GameComponentType.CLEANROOM)); + .thenApplyAsync((patch) -> patch.withId(GameComponentType.CLEANROOM)); } else { task = new ForgeNewInstallTask(dependencyManager, manifest, selfVersion, installer) - .thenApplyAsync((version) -> version.withId(GameComponentType.CLEANROOM)); + .thenApplyAsync((patch) -> patch.withId(GameComponentType.CLEANROOM)); } } 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 1cb5629e8d5..d7095012426 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 @@ -125,7 +125,7 @@ public void execute() throws IOException, VersionMismatchException, UnsupportedI */ public static boolean detectForgeInstallerType(DependencyManager dependencyManager, GameInstanceManifest manifest, Path installer) throws IOException, VersionMismatchException { Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(manifest); - if (!gameVersion.isPresent()) throw new IOException(); + if (gameVersion.isEmpty()) throw new IOException(); try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); From 60e8b66cdd08367b509a8be51e4b537fdef2761a Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 10 Aug 2026 21:14:45 +0800 Subject: [PATCH 157/199] refactor(DefaultGameRepository): simplify save and saveAsync methods for improved clarity --- .../hmcl/game/DefaultGameRepository.java | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) 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 23290504497..820134d0be0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -510,6 +510,22 @@ public Path getInstanceJson(GameInstanceID instanceId) { return getLayout().getInstanceJson(instanceId); } + public GameInstanceManifest save(GameInstanceManifest instanceManifest) throws IOException { + Path json = getInstanceJson(instanceManifest.id()).toAbsolutePath(); + Files.createDirectories(json.getParent()); + JsonUtils.writeToJsonFile(json, instanceManifest); + + DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); + DefaultGameInstance existing = newSnapshot.get(instanceManifest.id()); + if (existing != null) { + newSnapshot.put(existing.withManifest(newSnapshot, instanceManifest)); + } else { + newSnapshot.put(createInstance(newSnapshot, instanceManifest.id(), instanceManifest)); + } + publishSnapshot(newSnapshot); + return instanceManifest; + } + /// Saves a stored manifest without applying derived launch-view normalization. /// /// The returned task writes the manifest and publishes a snapshot containing exactly that @@ -518,21 +534,7 @@ public Path getInstanceJson(GameInstanceID instanceId) { /// @param instanceManifest the persistent manifest to save /// @return the task that saves and publishes the manifest public Task saveAsync(GameInstanceManifest instanceManifest) { - return Task.supplyAsync(() -> { - Path json = getInstanceJson(instanceManifest.id()).toAbsolutePath(); - Files.createDirectories(json.getParent()); - JsonUtils.writeToJsonFile(json, instanceManifest); - - DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); - DefaultGameInstance existing = newSnapshot.get(instanceManifest.id()); - if (existing != null) { - newSnapshot.put(existing.withManifest(newSnapshot, instanceManifest)); - } else { - newSnapshot.put(createInstance(newSnapshot, instanceManifest.id(), instanceManifest)); - } - publishSnapshot(newSnapshot); - return instanceManifest; - }); + return Task.supplyAsync(() -> save(instanceManifest)); } @Override From 1575888b0bcc0c23cfeb78f4963a55846c98d375 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 10 Aug 2026 21:26:57 +0800 Subject: [PATCH 158/199] Add GameRepositoryDraft for staged instance index updates and single publish Assisted-by: grok-build:grok-4.5 --- .../org/jackhuang/hmcl/game/LogExporter.java | 1 - .../hmcl/download/DefaultGameBuilder.java | 40 +++-- .../hmcl/game/DefaultGameRepository.java | 27 +-- .../hmcl/game/DefaultGameRepositoryDraft.java | 165 ++++++++++++++++++ .../jackhuang/hmcl/game/GameRepository.java | 6 + .../hmcl/game/GameRepositoryDraft.java | 99 +++++++++++ 6 files changed, 313 insertions(+), 25 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java index ed6258d17fe..380f4d57b52 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -45,7 +45,6 @@ public static CompletableFuture exportLogs( PathMatcher logMatcher) { DefaultGameInstance instance = repository.getSnapshot().findInstance(instanceId); Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory(); - Path baseDirectory = repository.getBaseDirectory(); List instances = new ArrayList<>(); GameInstanceID currentInstanceId = instanceId; 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 783b27a71d8..c3d3516f9e4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -17,19 +17,21 @@ */ package org.jackhuang.hmcl.download; -import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.GameRepositoryDraft; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.function.ExceptionalFunction; +import java.io.IOException; import java.util.ArrayList; import java.util.Map; import java.util.Objects; /** - * Builds a new game instance by first saving a placeholder instance, then installing components - * against that registered instance. + * Builds a new game instance by registering a placeholder via [GameRepositoryDraft], installing + * components against that instance, then saving the final manifest. * * @author huangyuhui */ @@ -64,14 +66,26 @@ public Task buildAsync() { remoteVersion.getSelfVersion()))); } - // Register a placeholder instance first so every install step has a real GameInstance - // (paths, snapshot identity). On failure the whole instance directory is removed. - return dependencyManager.getGameRepository() - .saveAsync(new GameInstanceManifest(name)) - .thenComposeAsync(placeholder -> { - DefaultGameInstance instance = dependencyManager.getGameRepository().getInstance(name); + DefaultGameRepository repository = dependencyManager.getGameRepository(); - Task libraryTask = Task.completed(placeholder); + // Register the placeholder instance through a draft (single index publish), then install + // components. A final saveAsync publishes the completed manifest. + return Task.supplyAsync(() -> { + GameRepositoryDraft draft = repository.openDraft(); + try { + GameInstance instance = draft.put(new GameInstanceManifest(name)); + draft.commit(); + return instance; + } catch (IOException e) { + draft.abort(); + throw e; + } catch (RuntimeException e) { + draft.abort(); + throw e; + } + }) + .thenComposeAsync(instance -> { + Task libraryTask = Task.completed(instance.getManifest()); libraryTask = libraryTask.thenComposeAsync( libraryTaskHelper(instance, gameVersion, "game", gameVersion)); @@ -81,11 +95,11 @@ public Task buildAsync() { } for (RemoteVersion remoteVersion : remoteVersions) { - libraryTask = libraryTask.thenComposeAsync( - working -> dependencyManager.installComponentAsync(instance, working, remoteVersion)); + libraryTask = libraryTask.thenComposeAsync(working -> + dependencyManager.installComponentAsync(instance, working, remoteVersion)); } - return libraryTask.thenComposeAsync(dependencyManager.getGameRepository()::saveAsync); + return libraryTask.thenComposeAsync(repository::saveAsync); }) .whenComplete(exception -> { if (exception != null) { 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 820134d0be0..d821e06e878 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -510,19 +510,24 @@ public Path getInstanceJson(GameInstanceID instanceId) { return getLayout().getInstanceJson(instanceId); } + /// Opens a draft for staging instance index changes and committing them once. + /// + /// @return a new open draft + @Override + public DefaultGameRepositoryDraft openDraft() { + return new DefaultGameRepositoryDraft(this); + } + + /// Writes a stored manifest and publishes a new snapshot in a single draft commit. + /// + /// @param instanceManifest the persistent manifest to save + /// @return the saved manifest + /// @throws IOException if the manifest cannot be written public GameInstanceManifest save(GameInstanceManifest instanceManifest) throws IOException { - Path json = getInstanceJson(instanceManifest.id()).toAbsolutePath(); - Files.createDirectories(json.getParent()); - JsonUtils.writeToJsonFile(json, instanceManifest); - - DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); - DefaultGameInstance existing = newSnapshot.get(instanceManifest.id()); - if (existing != null) { - newSnapshot.put(existing.withManifest(newSnapshot, instanceManifest)); - } else { - newSnapshot.put(createInstance(newSnapshot, instanceManifest.id(), instanceManifest)); + try (DefaultGameRepositoryDraft draft = openDraft()) { + draft.put(instanceManifest); + draft.commit(); } - publishSnapshot(newSnapshot); return instanceManifest; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java new file mode 100644 index 00000000000..682d4c7644b --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -0,0 +1,165 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Map; +import java.util.Set; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +/// Default [GameRepositoryDraft] backed by a COW clone of the published snapshot. +@NotNullByDefault +public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { + + private final DefaultGameRepository repository; + private final DefaultGameRepositorySnapshot working; + private final Set createdIds = new HashSet<>(); + private final Map originalManifests = new HashMap<>(); + private boolean committed; + private boolean closed; + + DefaultGameRepositoryDraft(DefaultGameRepository repository) { + this.repository = repository; + this.working = repository.getSnapshot().clone(); + } + + @Override + public DefaultGameRepository getRepository() { + return repository; + } + + @Override + public boolean isOpen() { + return !closed; + } + + @Override + public boolean isCommitted() { + return committed; + } + + @Override + public boolean hasInstance(GameInstanceID instanceId) { + checkOpen(); + return working.hasInstance(instanceId); + } + + @Override + public DefaultGameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException { + checkOpen(); + return working.getRegistered(instanceId); + } + + @Override + public DefaultGameInstance put(GameInstanceManifest manifest) throws IOException { + checkOpen(); + + GameInstanceID id = manifest.id(); + DefaultGameInstance existing = working.get(id); + if (existing != null) { + originalManifests.putIfAbsent(id, existing.getManifest()); + } else { + createdIds.add(id); + } + + Path json = repository.getInstanceJson(id).toAbsolutePath(); + Files.createDirectories(json.getParent()); + JsonUtils.writeToJsonFile(json, manifest); + + if (existing != null) { + working.put(existing.withManifest(working, manifest)); + } else { + working.put(repository.createInstance(working, id, manifest)); + } + return working.getRegistered(id); + } + + @Override + public void commit() { + checkOpen(); + if (committed) { + throw new IllegalStateException("Draft already committed"); + } + repository.publishSnapshot(working); + committed = true; + closed = true; + } + + @Override + public void abort() { + if (committed) { + throw new IllegalStateException("Draft already committed"); + } + if (closed) { + return; + } + closed = true; + + for (GameInstanceID id : createdIds) { + try { + deleteInstanceDirectory(id); + } catch (Exception e) { + LOG.warning("Failed to remove draft-created instance " + id, e); + } + } + + for (Map.Entry entry : originalManifests.entrySet()) { + if (createdIds.contains(entry.getKey())) { + continue; + } + try { + Path json = repository.getInstanceJson(entry.getKey()).toAbsolutePath(); + Files.createDirectories(json.getParent()); + JsonUtils.writeToJsonFile(json, entry.getValue()); + } catch (IOException e) { + LOG.warning("Failed to restore manifest for " + entry.getKey(), e); + } + } + } + + /// Deletes a draft-created instance directory without touching the published snapshot. + private void deleteInstanceDirectory(GameInstanceID id) throws IOException { + Path root = repository.getLayout().getInstanceRoot(id); + if (Files.notExists(root)) { + return; + } + FileUtils.deleteDirectory(root); + } + + @Override + public void close() { + if (!closed && !committed) { + abort(); + } + } + + private void checkOpen() { + if (closed) { + throw new IllegalStateException("Draft is closed"); + } + } +} 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 731cfe43fe1..e1ae9f0841d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -57,6 +57,12 @@ default Path getBaseDirectory() { /// @return the current repository snapshot GameRepositorySnapshot getSnapshot(); + /// Opens a draft for staging instance creates and manifest updates before a single publish. + /// + /// @return a new open draft cloned from [#getSnapshot()] + /// @see GameRepositoryDraft + GameRepositoryDraft openDraft(); + /// Resolves inheritance into a normalized launch view and a patch-preserving standalone view. /// /// @param manifest the manifest to resolve diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java new file mode 100644 index 00000000000..ce54c223f87 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -0,0 +1,99 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jetbrains.annotations.NotNullByDefault; + +import java.io.IOException; + +/// A mutable draft of [GameRepository] instance-index state. +/// +/// A draft is opened from a repository's published snapshot, accumulates instance creates and +/// manifest updates, then either [#commit()]s once (write-through JSON is already on disk; the +/// index is published) or [#abort()]s (restores previous JSON for edited instances and removes +/// directories created only in this draft). +/// +/// Drafts do not roll back global library or asset downloads that install tasks may have written +/// outside instance roots. +/// +/// @see GameRepository#openDraft() +@NotNullByDefault +public interface GameRepositoryDraft extends AutoCloseable { + + /// Returns the repository that owns this draft. + /// + /// @return the repository + GameRepository getRepository(); + + /// Returns whether this draft still accepts mutations. + /// + /// @return whether the draft is open + boolean isOpen(); + + /// Returns whether this draft has been committed. + /// + /// @return whether [#commit()] has completed successfully + boolean isCommitted(); + + /// Returns whether the working index contains an instance with the given id. + /// + /// @param instanceId the instance id + /// @return whether the instance exists in this draft + boolean hasInstance(GameInstanceID instanceId); + + /// Returns the instance as seen in this draft's working snapshot. + /// + /// @param instanceId the instance id + /// @return the working instance + /// @throws NoSuchGameInstanceException if the instance is absent from this draft + GameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException; + + /// Stages a stored instance manifest into this draft. + /// + /// Writes the manifest JSON under the repository layout and updates the working snapshot so + /// subsequent [#getInstance(GameInstanceID)] calls observe the new state. The published + /// repository index is unchanged until [#commit()]. + /// + /// @param manifest the persistent instance manifest + /// @return the working instance after the update + /// @throws IOException if the manifest cannot be written + /// @throws IllegalStateException if the draft is closed + GameInstance put(GameInstanceManifest manifest) throws IOException; + + /// Publishes this draft's working snapshot as the repository's current index. + /// + /// Instance JSON files are expected to already match the working state from prior [#put] calls. + /// + /// @throws IllegalStateException if the draft is closed or already committed + void commit(); + + /// Discards this draft without publishing. + /// + /// Restores JSON for instances that existed before the draft and were modified, and removes + /// instance directories that were created only in this draft. Global caches (libraries, assets) + /// are not reverted. + /// + /// @throws IllegalStateException if the draft was already committed + void abort(); + + /// Aborts this draft when it was not committed. + /// + /// @see #abort() + @Override + void close(); +} From 4b04b98c7d965fcdbb85f99f8c6c50d8dba019fd Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 10 Aug 2026 21:32:01 +0800 Subject: [PATCH 159/199] refactor(DefaultGameRepositoryDraft): enhance clarity and consistency in snapshot handling and method documentation --- .../hmcl/game/DefaultGameRepositoryDraft.java | 92 ++++++++++++++----- .../hmcl/game/GameRepositoryDraft.java | 53 +++++++---- 2 files changed, 107 insertions(+), 38 deletions(-) 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 682d4c7644b..1b5648d3244 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -31,55 +31,85 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/// Default [GameRepositoryDraft] backed by a COW clone of the published snapshot. +/// Default [GameRepositoryDraft] that holds a COW clone of the published snapshot as its working +/// index. +/// +/// [#put(GameInstanceManifest)] writes instance JSON immediately and updates [#getSnapshot()]. +/// [#commit()] seals and publishes that snapshot. [#abort()] restores JSON for instances modified +/// in this draft and deletes directories created only here; it does not revert library or asset +/// downloads outside instance roots. @NotNullByDefault public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { + /// Repository whose published index will be replaced on [#commit()]. private final DefaultGameRepository repository; - private final DefaultGameRepositorySnapshot working; + + /// Working snapshot for this draft; published on [#commit()]. + private final DefaultGameRepositorySnapshot snapshot; + + /// Instance ids that did not exist in the working snapshot when first staged by [#put]. private final Set createdIds = new HashSet<>(); + + /// First observed stored manifest for each id that already existed when staged by [#put]. + /// + /// Used by [#abort()] to restore on-disk JSON for edited instances. private final Map originalManifests = new HashMap<>(); + + /// Whether [#commit()] has completed successfully. private boolean committed; + + /// Whether this draft no longer accepts mutations. private boolean closed; + /// Creates a draft whose working snapshot is a clone of `repository`'s published snapshot. + /// + /// @param repository the repository that owns this draft DefaultGameRepositoryDraft(DefaultGameRepository repository) { this.repository = repository; - this.working = repository.getSnapshot().clone(); + this.snapshot = repository.getSnapshot().clone(); } + /// {@inheritDoc} @Override public DefaultGameRepository getRepository() { return repository; } + /// {@inheritDoc} + /// + /// @throws IllegalStateException if the draft was aborted or closed without commit + @Override + public DefaultGameRepositorySnapshot getSnapshot() { + if (closed && !committed) { + throw new IllegalStateException("Draft is closed"); + } + return snapshot; + } + + /// {@inheritDoc} @Override public boolean isOpen() { return !closed; } + /// {@inheritDoc} @Override public boolean isCommitted() { return committed; } - @Override - public boolean hasInstance(GameInstanceID instanceId) { - checkOpen(); - return working.hasInstance(instanceId); - } - - @Override - public DefaultGameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException { - checkOpen(); - return working.getRegistered(instanceId); - } - + /// {@inheritDoc} + /// + /// Writes `manifest` to the instance JSON path, records abort metadata, and replaces or creates + /// the instance entry in [#getSnapshot()]. + /// + /// @throws IllegalStateException if the draft is closed @Override public DefaultGameInstance put(GameInstanceManifest manifest) throws IOException { checkOpen(); GameInstanceID id = manifest.id(); - DefaultGameInstance existing = working.get(id); + DefaultGameInstance existing = snapshot.get(id); if (existing != null) { originalManifests.putIfAbsent(id, existing.getManifest()); } else { @@ -91,24 +121,35 @@ public DefaultGameInstance put(GameInstanceManifest manifest) throws IOException JsonUtils.writeToJsonFile(json, manifest); if (existing != null) { - working.put(existing.withManifest(working, manifest)); + snapshot.put(existing.withManifest(snapshot, manifest)); } else { - working.put(repository.createInstance(working, id, manifest)); + snapshot.put(repository.createInstance(snapshot, id, manifest)); } - return working.getRegistered(id); + return snapshot.getRegistered(id); } + /// {@inheritDoc} + /// + /// Seals [#getSnapshot()] and installs it as the repository's published index. After this method + /// returns, the draft is closed and only [#isCommitted()] / [#getSnapshot()] remain meaningful. + /// + /// @throws IllegalStateException if the draft is closed or already committed @Override public void commit() { checkOpen(); if (committed) { throw new IllegalStateException("Draft already committed"); } - repository.publishSnapshot(working); + repository.publishSnapshot(snapshot); committed = true; closed = true; } + /// {@inheritDoc} + /// + /// Idempotent when already aborted. Does not publish the working snapshot. + /// + /// @throws IllegalStateException if the draft was already committed @Override public void abort() { if (committed) { @@ -141,7 +182,10 @@ public void abort() { } } - /// Deletes a draft-created instance directory without touching the published snapshot. + /// Deletes a draft-created instance directory without modifying the published snapshot. + /// + /// @param id the instance id whose root directory will be removed + /// @throws IOException if deletion fails private void deleteInstanceDirectory(GameInstanceID id) throws IOException { Path root = repository.getLayout().getInstanceRoot(id); if (Files.notExists(root)) { @@ -150,6 +194,9 @@ private void deleteInstanceDirectory(GameInstanceID id) throws IOException { FileUtils.deleteDirectory(root); } + /// {@inheritDoc} + /// + /// Calls [#abort()] when the draft is still open and not committed. @Override public void close() { if (!closed && !committed) { @@ -157,6 +204,9 @@ public void close() { } } + /// Ensures the draft still accepts mutations. + /// + /// @throws IllegalStateException if the draft is closed private void checkOpen() { if (closed) { throw new IllegalStateException("Draft is closed"); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java index ce54c223f87..23340d09813 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -21,17 +21,23 @@ import java.io.IOException; -/// A mutable draft of [GameRepository] instance-index state. +/// A mutable draft of repository instance-index state, held as a working [GameRepositorySnapshot]. /// -/// A draft is opened from a repository's published snapshot, accumulates instance creates and -/// manifest updates, then either [#commit()]s once (write-through JSON is already on disk; the -/// index is published) or [#abort()]s (restores previous JSON for edited instances and removes -/// directories created only in this draft). +/// A draft is opened from a repository's published snapshot (typically by cloning it). Mutations +/// such as [#put(GameInstanceManifest)] update that working snapshot. [#commit()] publishes it as +/// the repository's current index; [#abort()] discards it and restores on-disk JSON for instances +/// modified in the draft. +/// +/// While the draft is open, [#getSnapshot()] is the draft's working snapshot and may still be +/// mutable. After [#commit()], the same snapshot object is sealed and becomes the repository's +/// published index. After [#abort()] or [#close()] without commit, the working snapshot must not be +/// used. /// /// Drafts do not roll back global library or asset downloads that install tasks may have written /// outside instance roots. /// /// @see GameRepository#openDraft() +/// @see #getSnapshot() @NotNullByDefault public interface GameRepositoryDraft extends AutoCloseable { @@ -40,6 +46,15 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @return the repository GameRepository getRepository(); + /// Returns the working snapshot held by this draft. + /// + /// This is the sole instance index mutated by the draft. Instances obtained from it belong to + /// this snapshot in the same way as for a published [GameRepositorySnapshot]. + /// + /// @return the working snapshot + /// @throws IllegalStateException if the draft is closed without having been committed + GameRepositorySnapshot getSnapshot(); + /// Returns whether this draft still accepts mutations. /// /// @return whether the draft is open @@ -50,32 +65,36 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @return whether [#commit()] has completed successfully boolean isCommitted(); - /// Returns whether the working index contains an instance with the given id. + /// Returns whether the working snapshot contains an instance with the given id. /// /// @param instanceId the instance id - /// @return whether the instance exists in this draft - boolean hasInstance(GameInstanceID instanceId); + /// @return whether the instance exists in [#getSnapshot()] + default boolean hasInstance(GameInstanceID instanceId) { + return getSnapshot().hasInstance(instanceId); + } - /// Returns the instance as seen in this draft's working snapshot. + /// Returns the instance as seen in [#getSnapshot()]. /// /// @param instanceId the instance id /// @return the working instance /// @throws NoSuchGameInstanceException if the instance is absent from this draft - GameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException; + default GameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException { + return getSnapshot().getInstance(instanceId); + } - /// Stages a stored instance manifest into this draft. + /// Stages a stored instance manifest into this draft's snapshot. /// - /// Writes the manifest JSON under the repository layout and updates the working snapshot so - /// subsequent [#getInstance(GameInstanceID)] calls observe the new state. The published - /// repository index is unchanged until [#commit()]. + /// Writes the manifest JSON under the repository layout and updates [#getSnapshot()] so + /// subsequent lookups observe the new state. The repository's published index is unchanged + /// until [#commit()]. /// /// @param manifest the persistent instance manifest - /// @return the working instance after the update - /// @throws IOException if the manifest cannot be written + /// @return the working instance after the update (from [#getSnapshot()]) + /// @throws IOException if the manifest cannot be written /// @throws IllegalStateException if the draft is closed GameInstance put(GameInstanceManifest manifest) throws IOException; - /// Publishes this draft's working snapshot as the repository's current index. + /// Publishes [#getSnapshot()] as the repository's current index. /// /// Instance JSON files are expected to already match the working state from prior [#put] calls. /// From 433109dfbdc2f02f401e950386d54e072dd5eb08 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 10 Aug 2026 21:38:05 +0800 Subject: [PATCH 160/199] refactor(DefaultGameBuilder, DefaultGameRepositoryDraft, GameRepositoryDraft): improve documentation and clarify draft handling logic --- .../hmcl/download/DefaultGameBuilder.java | 37 +++-- .../hmcl/game/DefaultGameRepositoryDraft.java | 135 ++++++++++++------ .../hmcl/game/GameRepositoryDraft.java | 78 +++++----- 3 files changed, 160 insertions(+), 90 deletions(-) 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 c3d3516f9e4..85d5c12ae03 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -23,30 +23,46 @@ import org.jackhuang.hmcl.game.GameRepositoryDraft; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.function.ExceptionalFunction; +import org.jetbrains.annotations.NotNullByDefault; import java.io.IOException; import java.util.ArrayList; import java.util.Map; import java.util.Objects; -/** - * Builds a new game instance by registering a placeholder via [GameRepositoryDraft], installing - * components against that instance, then saving the final manifest. - * - * @author huangyuhui - */ +/// Builds a new game instance by registering a placeholder through [GameRepositoryDraft], installing +/// components against that instance, then saving the completed manifest. +/// +/// On failure after the placeholder is committed, [#buildAsync()] removes the instance directory. +@NotNullByDefault public class DefaultGameBuilder extends GameBuilder { + /// Dependency manager used for component installation and repository access. private final DefaultDependencyManager dependencyManager; + /// Creates a builder bound to the given dependency manager. + /// + /// @param dependencyManager the dependency manager for the target repository public DefaultGameBuilder(DefaultDependencyManager dependencyManager) { this.dependencyManager = dependencyManager; } + /// Returns the dependency manager used by this builder. + /// + /// @return the dependency manager public DefaultDependencyManager getDependencyManager() { return dependencyManager; } + /// {@inheritDoc} + /// + /// Registers [#name] via a draft commit, installs the configured game and optional loaders into + /// that instance, then [DefaultGameRepository#saveAsync(GameInstanceManifest)] the final + /// manifest. If any step fails after the placeholder is published, the instance is removed from + /// disk. + /// + /// @return the build task + /// @throws NullPointerException if [#name] was not set @Override public Task buildAsync() { Objects.requireNonNull(name, "GameBuilder.name must be set"); @@ -68,8 +84,6 @@ public Task buildAsync() { DefaultGameRepository repository = dependencyManager.getGameRepository(); - // Register the placeholder instance through a draft (single index publish), then install - // components. A final saveAsync publishes the completed manifest. return Task.supplyAsync(() -> { GameRepositoryDraft draft = repository.openDraft(); try { @@ -109,6 +123,13 @@ public Task buildAsync() { .withStagesHints(hints); } + /// Returns a step that installs one remote component into the working manifest. + /// + /// @param instance the registered instance + /// @param gameVersion the Minecraft version used to look up the remote list + /// @param libraryId the component list id + /// @param libraryVersion the component version id + /// @return a function from the current working manifest to the install task private ExceptionalFunction, ?> libraryTaskHelper( GameInstance instance, String gameVersion, 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 1b5648d3244..e17e35c9a6b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -31,42 +32,47 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/// Default [GameRepositoryDraft] that holds a COW clone of the published snapshot as its working -/// index. +/// Default [GameRepositoryDraft] that keeps the published snapshot immutable and stages changes +/// separately until [#commit()]. /// -/// [#put(GameInstanceManifest)] writes instance JSON immediately and updates [#getSnapshot()]. -/// [#commit()] seals and publishes that snapshot. [#abort()] restores JSON for instances modified -/// in this draft and deletes directories created only here; it does not revert library or asset -/// downloads outside instance roots. +/// The draft holds [#base] (the repository snapshot at open time) and a map of staged manifests. +/// [#put(GameInstanceManifest)] only updates that map and instance JSON. [#commit()] clones +/// [#base] once, applies staged manifests, and publishes the result. @NotNullByDefault public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { /// Repository whose published index will be replaced on [#commit()]. private final DefaultGameRepository repository; - /// Working snapshot for this draft; published on [#commit()]. - private final DefaultGameRepositorySnapshot snapshot; + /// Immutable published snapshot captured when this draft was opened. + private final DefaultGameRepositorySnapshot base; - /// Instance ids that did not exist in the working snapshot when first staged by [#put]. + /// Staged stored manifests keyed by instance id; applied only on [#commit()]. + private final Map staged = new HashMap<>(); + + /// Instance ids that were not present in [#base] when first staged. private final Set createdIds = new HashSet<>(); - /// First observed stored manifest for each id that already existed when staged by [#put]. + /// Base stored manifests for ids that existed in [#base] and were later staged. /// - /// Used by [#abort()] to restore on-disk JSON for edited instances. + /// Used by [#abort()] to restore on-disk JSON. private final Map originalManifests = new HashMap<>(); + /// Snapshot published by [#commit()], or `null` before a successful commit. + private @Nullable DefaultGameRepositorySnapshot committedSnapshot; + /// Whether [#commit()] has completed successfully. private boolean committed; /// Whether this draft no longer accepts mutations. private boolean closed; - /// Creates a draft whose working snapshot is a clone of `repository`'s published snapshot. + /// Creates a draft over `repository`'s current published snapshot as an immutable base. /// /// @param repository the repository that owns this draft DefaultGameRepositoryDraft(DefaultGameRepository repository) { this.repository = repository; - this.snapshot = repository.getSnapshot().clone(); + this.base = repository.getSnapshot(); } /// {@inheritDoc} @@ -77,13 +83,18 @@ public DefaultGameRepository getRepository() { /// {@inheritDoc} /// + /// Returns [#base] while open, or the snapshot published by [#commit()] after success. + /// /// @throws IllegalStateException if the draft was aborted or closed without commit @Override public DefaultGameRepositorySnapshot getSnapshot() { - if (closed && !committed) { + if (committed) { + return committedSnapshot; + } + if (closed) { throw new IllegalStateException("Draft is closed"); } - return snapshot; + return base; } /// {@inheritDoc} @@ -98,10 +109,30 @@ public boolean isCommitted() { return committed; } + /// {@inheritDoc} + @Override + public boolean hasInstance(GameInstanceID instanceId) { + checkOpen(); + if (staged.containsKey(instanceId)) { + return true; + } + return base.hasInstance(instanceId); + } + + /// {@inheritDoc} + @Override + public DefaultGameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException { + checkOpen(); + GameInstanceManifest stagedManifest = staged.get(instanceId); + if (stagedManifest != null) { + return instanceView(instanceId, stagedManifest); + } + return base.getRegistered(instanceId); + } + /// {@inheritDoc} /// - /// Writes `manifest` to the instance JSON path, records abort metadata, and replaces or creates - /// the instance entry in [#getSnapshot()]. + /// Writes `manifest` to disk and records it in the staged map. Does not modify [#base]. /// /// @throws IllegalStateException if the draft is closed @Override @@ -109,10 +140,10 @@ public DefaultGameInstance put(GameInstanceManifest manifest) throws IOException checkOpen(); GameInstanceID id = manifest.id(); - DefaultGameInstance existing = snapshot.get(id); - if (existing != null) { - originalManifests.putIfAbsent(id, existing.getManifest()); - } else { + DefaultGameInstance existingInBase = base.get(id); + if (existingInBase != null) { + originalManifests.putIfAbsent(id, existingInBase.getManifest()); + } else if (!staged.containsKey(id)) { createdIds.add(id); } @@ -120,18 +151,13 @@ public DefaultGameInstance put(GameInstanceManifest manifest) throws IOException Files.createDirectories(json.getParent()); JsonUtils.writeToJsonFile(json, manifest); - if (existing != null) { - snapshot.put(existing.withManifest(snapshot, manifest)); - } else { - snapshot.put(repository.createInstance(snapshot, id, manifest)); - } - return snapshot.getRegistered(id); + staged.put(id, manifest); + return instanceView(id, manifest); } /// {@inheritDoc} /// - /// Seals [#getSnapshot()] and installs it as the repository's published index. After this method - /// returns, the draft is closed and only [#isCommitted()] / [#getSnapshot()] remain meaningful. + /// Clones [#base], applies all staged manifests onto the clone, seals it, and publishes it. /// /// @throws IllegalStateException if the draft is closed or already committed @Override @@ -140,14 +166,28 @@ public void commit() { if (committed) { throw new IllegalStateException("Draft already committed"); } - repository.publishSnapshot(snapshot); + + DefaultGameRepositorySnapshot next = base.clone(); + for (Map.Entry entry : staged.entrySet()) { + GameInstanceID id = entry.getKey(); + GameInstanceManifest manifest = entry.getValue(); + DefaultGameInstance existing = next.get(id); + if (existing != null) { + next.put(existing.withManifest(next, manifest)); + } else { + next.put(repository.createInstance(next, id, manifest)); + } + } + + repository.publishSnapshot(next); + committedSnapshot = next; committed = true; closed = true; } /// {@inheritDoc} /// - /// Idempotent when already aborted. Does not publish the working snapshot. + /// Idempotent when already aborted. Does not publish a snapshot. /// /// @throws IllegalStateException if the draft was already committed @Override @@ -182,6 +222,29 @@ public void abort() { } } + /// {@inheritDoc} + /// + /// Calls [#abort()] when the draft is still open and not committed. + @Override + public void close() { + if (!closed && !committed) { + abort(); + } + } + + /// Returns an instance bound to [#base] that exposes the staged stored manifest. + /// + /// @param id the instance id + /// @param manifest the staged stored manifest + /// @return an instance view for this draft + private DefaultGameInstance instanceView(GameInstanceID id, GameInstanceManifest manifest) { + DefaultGameInstance existingInBase = base.get(id); + if (existingInBase != null) { + return existingInBase.withManifest(base, manifest); + } + return repository.createInstance(base, id, manifest); + } + /// Deletes a draft-created instance directory without modifying the published snapshot. /// /// @param id the instance id whose root directory will be removed @@ -194,16 +257,6 @@ private void deleteInstanceDirectory(GameInstanceID id) throws IOException { FileUtils.deleteDirectory(root); } - /// {@inheritDoc} - /// - /// Calls [#abort()] when the draft is still open and not committed. - @Override - public void close() { - if (!closed && !committed) { - abort(); - } - } - /// Ensures the draft still accepts mutations. /// /// @throws IllegalStateException if the draft is closed diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java index 23340d09813..fb99b63cb0b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -21,23 +21,22 @@ import java.io.IOException; -/// A mutable draft of repository instance-index state, held as a working [GameRepositorySnapshot]. +/// A write session over a [GameRepository] that stages instance-index changes without mutating +/// snapshots before publish. /// -/// A draft is opened from a repository's published snapshot (typically by cloning it). Mutations -/// such as [#put(GameInstanceManifest)] update that working snapshot. [#commit()] publishes it as -/// the repository's current index; [#abort()] discards it and restores on-disk JSON for instances -/// modified in the draft. +/// The draft holds the repository's published [GameRepositorySnapshot] at open time as an immutable +/// base. [#put(GameInstanceManifest)] records staged manifests and writes instance JSON; it does +/// not modify that base snapshot. [#commit()] builds a new snapshot from the base plus staged +/// changes and publishes it once. [#abort()] discards staged changes, restores JSON for edited +/// instances, and removes directories created only in this draft. /// -/// While the draft is open, [#getSnapshot()] is the draft's working snapshot and may still be -/// mutable. After [#commit()], the same snapshot object is sealed and becomes the repository's -/// published index. After [#abort()] or [#close()] without commit, the working snapshot must not be -/// used. +/// [#getSnapshot()] returns the immutable base while the draft is open. Staged state is visible +/// through [#getInstance(GameInstanceID)] and [#hasInstance(GameInstanceID)], which overlay the +/// base. After a successful [#commit()], [#getSnapshot()] returns the newly published snapshot. /// -/// Drafts do not roll back global library or asset downloads that install tasks may have written -/// outside instance roots. +/// Drafts do not roll back global library or asset downloads outside instance roots. /// /// @see GameRepository#openDraft() -/// @see #getSnapshot() @NotNullByDefault public interface GameRepositoryDraft extends AutoCloseable { @@ -46,13 +45,14 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @return the repository GameRepository getRepository(); - /// Returns the working snapshot held by this draft. + /// Returns the immutable base snapshot captured when this draft was opened, or the published + /// snapshot after a successful [#commit()]. /// - /// This is the sole instance index mutated by the draft. Instances obtained from it belong to - /// this snapshot in the same way as for a published [GameRepositorySnapshot]. + /// While the draft is open, this is not a mutable working copy: staged [#put] results are not + /// reflected here. Use [#getInstance(GameInstanceID)] for the draft's effective instance view. /// - /// @return the working snapshot - /// @throws IllegalStateException if the draft is closed without having been committed + /// @return the base snapshot, or the committed published snapshot + /// @throws IllegalStateException if the draft was aborted or closed without commit GameRepositorySnapshot getSnapshot(); /// Returns whether this draft still accepts mutations. @@ -65,47 +65,43 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @return whether [#commit()] has completed successfully boolean isCommitted(); - /// Returns whether the working snapshot contains an instance with the given id. + /// Returns whether the draft's effective index contains an instance with the given id. + /// + /// An id is present if it is staged by [#put] or present in the base snapshot and not removed + /// by this draft. /// /// @param instanceId the instance id - /// @return whether the instance exists in [#getSnapshot()] - default boolean hasInstance(GameInstanceID instanceId) { - return getSnapshot().hasInstance(instanceId); - } + /// @return whether the instance exists in the draft view + boolean hasInstance(GameInstanceID instanceId); - /// Returns the instance as seen in [#getSnapshot()]. + /// Returns the instance as seen by this draft (staged manifest over the base snapshot). /// /// @param instanceId the instance id - /// @return the working instance - /// @throws NoSuchGameInstanceException if the instance is absent from this draft - default GameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException { - return getSnapshot().getInstance(instanceId); - } + /// @return the effective instance for this draft + /// @throws NoSuchGameInstanceException if the instance is absent from the draft view + GameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException; - /// Stages a stored instance manifest into this draft's snapshot. + /// Stages a stored instance manifest without modifying the base snapshot. /// - /// Writes the manifest JSON under the repository layout and updates [#getSnapshot()] so - /// subsequent lookups observe the new state. The repository's published index is unchanged - /// until [#commit()]. + /// Writes the manifest JSON under the repository layout and records the change for + /// [#commit()]. The repository's published index is unchanged until commit. /// /// @param manifest the persistent instance manifest - /// @return the working instance after the update (from [#getSnapshot()]) - /// @throws IOException if the manifest cannot be written - /// @throws IllegalStateException if the draft is closed + /// @return an instance view reflecting `manifest` in this draft + /// @throws IOException if the manifest cannot be written + /// @throws IllegalStateException if the draft is closed GameInstance put(GameInstanceManifest manifest) throws IOException; - /// Publishes [#getSnapshot()] as the repository's current index. - /// - /// Instance JSON files are expected to already match the working state from prior [#put] calls. + /// Builds a new snapshot from the base plus staged changes and publishes it. /// /// @throws IllegalStateException if the draft is closed or already committed void commit(); - /// Discards this draft without publishing. + /// Discards staged changes without publishing a new snapshot. /// - /// Restores JSON for instances that existed before the draft and were modified, and removes + /// Restores JSON for instances that existed in the base and were modified, and removes /// instance directories that were created only in this draft. Global caches (libraries, assets) - /// are not reverted. + /// are not reverted. Idempotent when already aborted. /// /// @throws IllegalStateException if the draft was already committed void abort(); From 704fa525a6afce7cd6d275ad37671c90d5c9637a Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 10 Aug 2026 21:40:29 +0800 Subject: [PATCH 161/199] refactor(DefaultGameBuilder, DefaultGameRepositoryDraft, GameRepositoryDraft): enhance documentation and clarify draft handling logic --- .../hmcl/download/DefaultGameBuilder.java | 4 +- .../hmcl/game/DefaultGameRepositoryDraft.java | 44 +++---------- .../hmcl/game/GameRepositoryDraft.java | 65 +++++++++---------- 3 files changed, 40 insertions(+), 73 deletions(-) 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 85d5c12ae03..1038d918f7b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -87,9 +87,9 @@ public Task buildAsync() { return Task.supplyAsync(() -> { GameRepositoryDraft draft = repository.openDraft(); try { - GameInstance instance = draft.put(new GameInstanceManifest(name)); + draft.put(new GameInstanceManifest(name)); draft.commit(); - return instance; + return repository.getInstance(name); } catch (IOException e) { draft.abort(); throw e; 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 e17e35c9a6b..9fec4dd8032 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -32,12 +32,13 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/// Default [GameRepositoryDraft] that keeps the published snapshot immutable and stages changes -/// separately until [#commit()]. +/// Default [GameRepositoryDraft] that keeps the published snapshot immutable and stages stored +/// manifests until [#commit()]. /// -/// The draft holds [#base] (the repository snapshot at open time) and a map of staged manifests. +/// The draft holds [#base] (the repository snapshot at open time) and [#staged] manifests. /// [#put(GameInstanceManifest)] only updates that map and instance JSON. [#commit()] clones -/// [#base] once, applies staged manifests, and publishes the result. +/// [#base] once, applies staged manifests, and publishes the result. No [GameInstance] is produced +/// by the draft itself. @NotNullByDefault public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { @@ -113,30 +114,17 @@ public boolean isCommitted() { @Override public boolean hasInstance(GameInstanceID instanceId) { checkOpen(); - if (staged.containsKey(instanceId)) { - return true; - } - return base.hasInstance(instanceId); - } - - /// {@inheritDoc} - @Override - public DefaultGameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException { - checkOpen(); - GameInstanceManifest stagedManifest = staged.get(instanceId); - if (stagedManifest != null) { - return instanceView(instanceId, stagedManifest); - } - return base.getRegistered(instanceId); + return staged.containsKey(instanceId) || base.hasInstance(instanceId); } /// {@inheritDoc} /// - /// Writes `manifest` to disk and records it in the staged map. Does not modify [#base]. + /// Writes `manifest` to disk and records it in [#staged]. Does not modify [#base] and does not + /// create a [GameInstance]. /// /// @throws IllegalStateException if the draft is closed @Override - public DefaultGameInstance put(GameInstanceManifest manifest) throws IOException { + public void put(GameInstanceManifest manifest) throws IOException { checkOpen(); GameInstanceID id = manifest.id(); @@ -152,7 +140,6 @@ public DefaultGameInstance put(GameInstanceManifest manifest) throws IOException JsonUtils.writeToJsonFile(json, manifest); staged.put(id, manifest); - return instanceView(id, manifest); } /// {@inheritDoc} @@ -232,19 +219,6 @@ public void close() { } } - /// Returns an instance bound to [#base] that exposes the staged stored manifest. - /// - /// @param id the instance id - /// @param manifest the staged stored manifest - /// @return an instance view for this draft - private DefaultGameInstance instanceView(GameInstanceID id, GameInstanceManifest manifest) { - DefaultGameInstance existingInBase = base.get(id); - if (existingInBase != null) { - return existingInBase.withManifest(base, manifest); - } - return repository.createInstance(base, id, manifest); - } - /// Deletes a draft-created instance directory without modifying the published snapshot. /// /// @param id the instance id whose root directory will be removed diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java index fb99b63cb0b..7ffd4abf965 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -21,18 +21,14 @@ import java.io.IOException; -/// A write session over a [GameRepository] that stages instance-index changes without mutating -/// snapshots before publish. +/// A write session that stages stored instance manifests against an immutable base snapshot. /// -/// The draft holds the repository's published [GameRepositorySnapshot] at open time as an immutable -/// base. [#put(GameInstanceManifest)] records staged manifests and writes instance JSON; it does -/// not modify that base snapshot. [#commit()] builds a new snapshot from the base plus staged -/// changes and publishes it once. [#abort()] discards staged changes, restores JSON for edited -/// instances, and removes directories created only in this draft. -/// -/// [#getSnapshot()] returns the immutable base while the draft is open. Staged state is visible -/// through [#getInstance(GameInstanceID)] and [#hasInstance(GameInstanceID)], which overlay the -/// base. After a successful [#commit()], [#getSnapshot()] returns the newly published snapshot. +/// The draft holds the repository's published [GameRepositorySnapshot] at open time. That snapshot +/// is never modified. [#put(GameInstanceManifest)] only records a staged stored manifest and writes +/// its JSON. [#commit()] builds a new snapshot from the base plus staged manifests and publishes it +/// once; only then does the repository index contain the corresponding [GameInstance] values. +/// [#abort()] discards staged changes, restores JSON for edited base instances, and removes +/// directories created only in this draft. /// /// Drafts do not roll back global library or asset downloads outside instance roots. /// @@ -45,11 +41,11 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @return the repository GameRepository getRepository(); - /// Returns the immutable base snapshot captured when this draft was opened, or the published - /// snapshot after a successful [#commit()]. + /// Returns the immutable base snapshot captured when this draft was opened, or the snapshot + /// published by a successful [#commit()]. /// - /// While the draft is open, this is not a mutable working copy: staged [#put] results are not - /// reflected here. Use [#getInstance(GameInstanceID)] for the draft's effective instance view. + /// While the draft is open, staged [#put] results are not part of this snapshot. After commit, + /// this method returns the newly published index. /// /// @return the base snapshot, or the committed published snapshot /// @throws IllegalStateException if the draft was aborted or closed without commit @@ -65,43 +61,40 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @return whether [#commit()] has completed successfully boolean isCommitted(); - /// Returns whether the draft's effective index contains an instance with the given id. + /// Returns whether a stored manifest for `instanceId` is staged or already present in the base + /// snapshot. /// - /// An id is present if it is staged by [#put] or present in the base snapshot and not removed - /// by this draft. + /// This does not imply a [GameInstance] is available from the published repository until + /// [#commit()]. /// /// @param instanceId the instance id - /// @return whether the instance exists in the draft view + /// @return whether the id is staged or present in the base snapshot boolean hasInstance(GameInstanceID instanceId); - /// Returns the instance as seen by this draft (staged manifest over the base snapshot). - /// - /// @param instanceId the instance id - /// @return the effective instance for this draft - /// @throws NoSuchGameInstanceException if the instance is absent from the draft view - GameInstance getInstance(GameInstanceID instanceId) throws NoSuchGameInstanceException; - - /// Stages a stored instance manifest without modifying the base snapshot. + /// Stages a stored instance manifest without creating a repository [GameInstance]. /// /// Writes the manifest JSON under the repository layout and records the change for - /// [#commit()]. The repository's published index is unchanged until commit. + /// [#commit()]. No instance index entry exists for a newly staged id until commit. Callers that + /// need a [GameInstance] must [#commit()] and then use [GameRepository#getInstance(GameInstanceID)]. /// /// @param manifest the persistent instance manifest - /// @return an instance view reflecting `manifest` in this draft - /// @throws IOException if the manifest cannot be written - /// @throws IllegalStateException if the draft is closed - GameInstance put(GameInstanceManifest manifest) throws IOException; + /// @throws IOException if the manifest cannot be written + /// @throws IllegalStateException if the draft is closed + void put(GameInstanceManifest manifest) throws IOException; - /// Builds a new snapshot from the base plus staged changes and publishes it. + /// Builds a new snapshot from the base plus staged manifests and publishes it. + /// + /// After this method returns, [GameRepository#getInstance(GameInstanceID)] will resolve staged + /// ids from the published index. /// /// @throws IllegalStateException if the draft is closed or already committed void commit(); /// Discards staged changes without publishing a new snapshot. /// - /// Restores JSON for instances that existed in the base and were modified, and removes - /// instance directories that were created only in this draft. Global caches (libraries, assets) - /// are not reverted. Idempotent when already aborted. + /// Restores JSON for instances that existed in the base and were modified, and removes instance + /// directories that were created only in this draft. Global caches (libraries, assets) are not + /// reverted. Idempotent when already aborted. /// /// @throws IllegalStateException if the draft was already committed void abort(); From 5916f9db8b9e9e47a2425715a05fe057637aaf92 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 10 Aug 2026 21:52:41 +0800 Subject: [PATCH 162/199] refactor(LogExporter, GameCrashWindow, DefaultGameInstance): streamline log export process and enhance instance handling --- .../org/jackhuang/hmcl/game/LogExporter.java | 28 ++++++++++++------- .../jackhuang/hmcl/ui/GameCrashWindow.java | 2 +- .../hmcl/game/DefaultGameInstance.java | 4 +++ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java index 380f4d57b52..947e8a8dfd0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LogExporter.java @@ -18,13 +18,17 @@ package org.jackhuang.hmcl.game; import kala.encdet.EncodingDetector; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.Zipper; import org.jackhuang.hmcl.util.logging.Logger; import org.jackhuang.hmcl.util.platform.OperatingSystem; +import org.jetbrains.annotations.Nullable; import java.io.BufferedReader; import java.io.IOException; +import java.io.OutputStreamWriter; import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; import java.nio.file.DirectoryStream; import java.nio.file.Files; import java.nio.file.Path; @@ -41,22 +45,24 @@ private LogExporter() { } public static CompletableFuture exportLogs( - Path zipFile, DefaultGameRepository repository, GameInstanceID instanceId, String logs, String launchScript, + Path zipFile, DefaultGameInstance instance, LaunchOptions options, String logs, String launchScript, PathMatcher logMatcher) { - DefaultGameInstance instance = repository.getSnapshot().findInstance(instanceId); - Path runDirectory = instance != null ? instance.getRunDirectory() : repository.getBaseDirectory(); + DefaultGameRepositorySnapshot repositorySnapshot = instance.getSnapshot(); + Path runDirectory = options.getGameDir(); List instances = new ArrayList<>(); - GameInstanceID currentInstanceId = instanceId; + GameInstanceID currentInstanceId = instance.id; HashSet resolvedSoFar = new HashSet<>(); while (true) { if (resolvedSoFar.contains(currentInstanceId)) break; resolvedSoFar.add(currentInstanceId); - GameInstanceManifest currentVersion = repository.getInstanceManifest(currentInstanceId); + @Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(currentInstanceId); + if (currentInstance == null) + break; instances.add(currentInstanceId); - if (currentVersion.inheritsFrom() != null) { - currentInstanceId = currentVersion.inheritsFrom(); + if (currentInstance.getManifest().inheritsFrom() != null) { + currentInstanceId = currentInstance.getManifest().inheritsFrom(); } else { break; } @@ -74,9 +80,11 @@ public static CompletableFuture exportLogs( zipper.putTextFile(Logger.filterForbiddenToken(launchScript), OperatingSystem.CURRENT_OS == OperatingSystem.WINDOWS ? "launch.bat" : "launch.sh"); for (GameInstanceID id : instances) { - Path instanceJson = repository.getInstanceJson(id); - if (Files.exists(instanceJson)) { - zipper.putFile(instanceJson, id + ".json"); + @Nullable DefaultGameInstance currentInstance = repositorySnapshot.get(id); + if (currentInstance != null) { + try (var writer = new OutputStreamWriter(zipper.putStream(id + ".json"), StandardCharsets.UTF_8)) { + JsonUtils.GSON.toJson(currentInstance.getManifest(), writer); + } } } } catch (IOException e) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java index 48bb34cf228..5403517fe33 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/GameCrashWindow.java @@ -287,7 +287,7 @@ private CompletableFuture exportGameCrashInfo() { } }); - return LogExporter.exportLogs(logFile, gameInstance.getRepository(), launchOptions.getInstanceId(), logs, + return LogExporter.exportLogs(logFile, gameInstance, launchOptions, logs, new CommandBuilder().addAll(managedProcess.getCommands()).toString(), path -> { try { 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 2ac31c9551c..87a3a867424 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -138,6 +138,10 @@ public DefaultGameRepository getRepository() { return repository; } + public DefaultGameRepositorySnapshot getSnapshot() { + return snapshot; + } + @Override public DefaultGameRepositoryLayout getLayout() { return layout; From a1b7ed89f063dcace4fb08c2998cef6463524a9a Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 11 Aug 2026 21:24:16 +0800 Subject: [PATCH 163/199] Route game repository mutations through exclusive drafts Assisted-by: codex:gpt-5.6-sol --- .../hmcl/game/HMCLGameRepository.java | 159 +++-- .../hmcl/game/HMCLModpackInstallTask.java | 39 +- .../UpdateInstallerWizardProvider.java | 25 +- .../hmcl/ui/instances/InstallerListPage.java | 22 +- .../hmcl/ui/instances/Instances.java | 40 +- .../hmcl/download/DefaultGameBuilder.java | 45 +- .../hmcl/download/game/GameInstallTask.java | 29 +- .../hmcl/game/DefaultGameRepository.java | 254 +++++-- .../hmcl/game/DefaultGameRepositoryDraft.java | 626 ++++++++++++++---- .../game/DefaultGameRepositorySnapshot.java | 5 +- .../jackhuang/hmcl/game/GameInstanceID.java | 27 +- .../jackhuang/hmcl/game/GameRepository.java | 6 + .../hmcl/game/GameRepositoryDraft.java | 104 ++- .../hmcl/game/GameRepositoryDraftState.java | 39 ++ .../game/DefaultGameRepositoryDraftTest.java | 365 ++++++++++ 15 files changed, 1466 insertions(+), 319 deletions(-) create mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java create mode 100644 HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java 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 449593e4665..38adc5b5ace 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -64,6 +64,12 @@ public final class HMCLGameRepository extends DefaultGameRepository { /// The selected instance resolved from the current repository snapshot. private final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance; + /// Monitor guarding settings prepared before a new instance draft is opened. + private final Object preparedInstanceMonitor = new Object(); + + /// Settings reservations transferred to the next draft that creates the corresponding id. + private final Map preparedInstanceSettings = new HashMap<>(); + /// Creates a repository backed by the given game directory. /// /// @param gameDirectory the persistent game directory represented by this repository @@ -89,6 +95,51 @@ 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); + synchronized (preparedInstanceMonitor) { + 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) { + synchronized (preparedInstanceMonitor) { + 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 { + synchronized (preparedInstanceMonitor) { + 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); + } + } + @Override protected HMCLGameInstance createInstance( DefaultGameRepositorySnapshot snapshot, @@ -281,8 +332,8 @@ private void writeInstanceGameSettings(GameInstanceID instanceId, GameSettings.I /// Ensures the instance uses an isolated running directory under its instance root. /// /// When the instance is already registered, settings are updated through - /// [HMCLGameInstance]. Otherwise the isolation flag is written to the instance settings file - /// so a later [HMCLGameInstance#getRunDirectory] sees the isolated path. + /// [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) { @@ -299,16 +350,22 @@ public void ensureIsolatedRunningDirectory(GameInstanceID instanceId) { return; } - GameSettings.Instance setting = peekInstanceGameSettings(instanceId); - if (setting == null) { - setting = new GameSettings.Instance(); - } - if (setting.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY)) { - try { - writeInstanceGameSettings(instanceId, setting); - } catch (IOException e) { - LOG.warning("Failed to write isolated running directory for " + instanceId, e); + Path instanceRoot = getLayout().getInstanceRoot(instanceId).toAbsolutePath().normalize(); + synchronized (preparedInstanceMonitor) { + 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))); } } @@ -335,11 +392,21 @@ public void clean(GameInstanceID instanceId) throws IOException { clean(getInstance(instanceId).getRunDirectory()); } + /// Duplicates an instance and publishes the copy through one exclusive repository draft. + /// + /// The destination remains unpublished until all selected instance and run-directory files have + /// been copied. Failure aborts the draft and removes the destination instance root. + /// + /// @param srcId the source instance id + /// @param dstId the destination instance id + /// @param copySaves whether saved worlds should be copied + /// @throws IOException if the destination exists or any file cannot be copied or committed public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolean copySaves) throws IOException { Path srcDir = getLayout().getInstanceRoot(srcId); Path dstDir = getLayout().getInstanceRoot(dstId); GameInstanceManifest fromManifest = getInstanceManifest(srcId); + GameInstanceManifest destinationManifest = fromManifest.withId(dstId).withJar(dstId); List blackList = new ArrayList<>(ModAdviser.MODPACK_BLACK_LIST); blackList.add(srcId.id() + ".jar"); @@ -347,42 +414,41 @@ public void duplicateInstance(GameInstanceID srcId, GameInstanceID dstId, boolea if (!copySaves) blackList.add("saves"); - if (Files.exists(dstDir)) throw new IOException("Instance exists"); + try (DefaultGameRepositoryDraft draft = openDraft()) { + draft.put(destinationManifest); - Files.createDirectories(dstDir); - FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null)); + Files.createDirectories(dstDir); + FileUtils.copyDirectory(srcDir, dstDir, path -> Modpack.acceptFile(path, blackList, null)); - Path fromJson = srcDir.resolve(srcId.id() + ".json"); - Path fromJar = srcDir.resolve(srcId.id() + ".jar"); - Path toJson = dstDir.resolve(dstId.id() + ".json"); - Path toJar = dstDir.resolve(dstId.id() + ".jar"); + Path fromJar = srcDir.resolve(srcId.id() + ".jar"); + Path toJar = dstDir.resolve(dstId.id() + ".jar"); + if (Files.exists(fromJar)) { + Files.copy(fromJar, toJar); + } - if (Files.exists(fromJar)) { - Files.copy(fromJar, toJar); - } - Files.copy(fromJson, toJson); + Path srcGameDir = getInstance(srcId).getRunDirectory(); + boolean copyOriginalGameDir; + try { + copyOriginalGameDir = !Files.isSameFile(srcGameDir, srcDir); + } catch (IOException e) { + copyOriginalGameDir = true; + } - JsonUtils.writeToJsonFile(toJson, fromManifest.withId(dstId).withJar(dstId)); + GameSettings.Instance newGameSettings = getInstance(srcId).copySettings(); + newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); + newGameSettings.runningDirectoryProperty().setValue(""); + writeInstanceGameSettings(dstId, newGameSettings); + + Path dstGameDir = computeRunDirectory(dstId, false, newGameSettings); + if (copyOriginalGameDir) { + FileUtils.copyDirectory( + srcGameDir, + dstGameDir, + path -> Modpack.acceptFile(path, blackList, null)); + } - Path srcGameDir = getInstance(srcId).getRunDirectory(); - boolean copyOriginalGameDir; - try { - copyOriginalGameDir = !Files.isSameFile(srcGameDir, getLayout().getInstanceRoot(srcId)); - } catch (IOException e) { - copyOriginalGameDir = true; + draft.commit(); } - - GameSettings.Instance newGameSettings = getInstance(srcId).copySettings(); - newGameSettings.getOverrideProperties().add(GameSettings.PROPERTY_RUNNING_DIRECTORY); - newGameSettings.runningDirectoryProperty().setValue(""); - writeInstanceGameSettings(dstId, newGameSettings); - - Path dstGameDir = computeRunDirectory(dstId, false, newGameSettings); - - if (copyOriginalGameDir) - FileUtils.copyDirectory(srcGameDir, dstGameDir, path -> Modpack.acceptFile(path, blackList, null)); - - refresh(); } /// Returns instance-local settings for a registered instance ID, creating empty settings when @@ -542,4 +608,15 @@ public static long getAutoAllocatedMemory(long available) { 16L * 1024 * 1024 * 1024); return 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 f85248acb75..cf428997052 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -85,31 +85,32 @@ public List> getDependents() { return dependents; } + /// {@inheritDoc} @Override public void execute() throws Exception { String json = CompressingUtils.readTextZipEntry(zipFile, "minecraft/pack.json"); GameInstanceManifest originalManifest = JsonUtils.GSON.fromJson(json, GameInstanceManifest.class).withId(instanceId).withJar(null); GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null); - DefaultGameInstance instance = repository.getInstance(instanceId); - Task libraryTask = Task.completed(originalManifest); - // reinstall libraries - // libraries of Forge and OptiFine should be obtained by installation. - for (GameComponentAnalyzer.Mark mark : analyzer) { - if (mark.componentType() == GameComponentType.GAME) - continue; - String componentVersion = mark.version(); - if (componentVersion == null) { - continue; + dependencies.add(repository.updateInstanceAsync(instanceId, draftInstance -> { + Task libraryTask = Task.supplyAsync(() -> originalManifest); + // Forge and OptiFine libraries must be regenerated by their installers. + for (GameComponentAnalyzer.Mark mark : analyzer) { + if (mark.componentType() == GameComponentType.GAME) { + continue; + } + String componentVersion = mark.version(); + if (componentVersion == null) { + continue; + } + libraryTask = libraryTask.thenComposeAsync(manifest -> dependency.installComponentAsync( + draftInstance, + manifest, + modpack.getGameVersion(), + mark.componentType().getPatchId(), + componentVersion)); } - libraryTask = libraryTask.thenComposeAsync(manifest -> dependency.installComponentAsync( - instance, - manifest, - modpack.getGameVersion(), - mark.componentType().getPatchId(), - componentVersion)); - } - - dependencies.add(libraryTask.thenComposeAsync(repository::saveAsync)); + return libraryTask; + })); } } 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 8cbc8c0dc0f..d86a97b1a46 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 @@ -71,26 +71,33 @@ public Object finish(SettingsMap settings) { settings.put("success_message", i18n("install.success")); settings.put(FailureCallback.KEY, (settings1, exception, next) -> alertFailureMessage(exception, next)); - // Edit a working manifest in memory against the registered instance; save only on success - // so a failed install does not leave a half-written instance json. - Task ret = Task.supplyAsync(gameInstance::getManifest); var hints = new ArrayList(); for (Object value : settings.asStringMap().values()) { if (value instanceof RemoteVersion remoteVersion) { - ret = ret.thenComposeAsync(manifest -> - dependencyManager.installComponentAsync(gameInstance, manifest, remoteVersion)); hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()))); if ("game".equals(remoteVersion.getLibraryId())) { hints.add(new Task.StagesHint("hmcl.install.libraries")); hints.add(new Task.StagesHint("hmcl.install.assets")); } - } else if (value instanceof RemoveVersionAction removeVersionAction) { - ret = ret.thenComposeAsync(manifest -> - dependencyManager.removeComponentAsync(gameInstance, manifest, removeVersionAction.componentType)); } } - return ret.thenComposeAsync(gameInstance.getRepository()::saveAsync).thenComposeAsync(gameInstance.getRepository()::refreshAsync).withStagesHints(hints); + return gameInstance.getRepository().updateInstanceAsync(gameInstance.getId(), draftInstance -> { + Task update = Task.supplyAsync(draftInstance::getManifest); + for (Object value : settings.asStringMap().values()) { + if (value instanceof RemoteVersion remoteVersion) { + update = update.thenComposeAsync(manifest -> + dependencyManager.installComponentAsync(draftInstance, manifest, remoteVersion)); + } else if (value instanceof RemoveVersionAction removeVersionAction) { + update = update.thenComposeAsync(manifest -> + dependencyManager.removeComponentAsync( + draftInstance, + manifest, + removeVersionAction.componentType)); + } + } + return update; + }).withStagesHints(hints); } @Override 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 4ef10de7761..be353617daa 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 @@ -105,9 +105,11 @@ public void loadInstance(HMCLGameInstance.Optional instance) { Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType().getPatchId(), libraryVersion)); }); - component.setOnRemove(() -> repository.getDependency().removeComponentAsync(gameInstance, component.getComponentType()) - .thenComposeAsync(repository::saveAsync) - .withComposeAsync(repository.refreshAsync()) + component.setOnRemove(() -> repository.updateInstanceAsync( + gameInstance.getId(), + draftInstance -> repository.getDependency().removeComponentAsync( + draftInstance, + component.getComponentType())) .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) .start()); @@ -120,9 +122,11 @@ public void loadInstance(HMCLGameInstance.Optional instance) { InstallerItem installerItem = new InstallerItem(mark.componentType(), InstallerItem.Style.LIST_ITEM); installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false)); - installerItem.setOnRemove(() -> repository.getDependency().removeComponentAsync(gameInstance, mark.componentType()) - .thenComposeAsync(repository::saveAsync) - .withComposeAsync(repository.refreshAsync()) + installerItem.setOnRemove(() -> repository.updateInstanceAsync( + gameInstance.getId(), + draftInstance -> repository.getDependency().removeComponentAsync( + draftInstance, + mark.componentType())) .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) .start()); @@ -149,9 +153,9 @@ private void doInstallOffline(Path file) { } HMCLGameRepository repository = gameInstance.getRepository(); - Task task = repository.getDependency().installComponentAsync(gameInstance, file) - .thenComposeAsync(repository::saveAsync) - .thenComposeAsync(repository.refreshAsync()); + Task task = repository.updateInstanceAsync( + gameInstance.getId(), + draftInstance -> repository.getDependency().installComponentAsync(draftInstance, 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 0252d92e3b7..d3997cd5649 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 @@ -57,6 +57,7 @@ import java.util.List; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -191,17 +192,40 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { DefaultDependencyManager dependencyManager = repository.getDependency(); GameInstanceManifest newVersion = manifest.withId(instanceId).withJar(instanceId); + AtomicReference activeDraft = new AtomicReference<>(); Controllers.taskDialog( - Task.allOf(new GameDownloadTask(dependencyManager, null, newVersion), + Task.supplyAsync(() -> { + GameRepositoryDraft draft = repository.openDraft(); + activeDraft.set(draft); + draft.put(newVersion); + return draft; + }) + .thenComposeAsync(draft -> Task.allOf( + new GameDownloadTask(dependencyManager, null, newVersion), Task.allOf( - new GameAssetDownloadTask(dependencyManager, newVersion, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true), - new GameLibrariesTask(dependencyManager, newVersion, true) - ).withRunAsync(() -> { - // ignore failure - })) - .thenComposeAsync(repository.saveAsync(newVersion)) - .thenRunAsync(repository::refresh) + new GameAssetDownloadTask( + dependencyManager, + newVersion, + GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, + true), + new GameLibrariesTask(dependencyManager, newVersion, true)) + .withRunAsync(() -> { + // ignore failure + }))) + .thenAcceptAsync(ignored -> { + GameRepositoryDraft draft = activeDraft.get(); + if (draft == null) { + throw new IllegalStateException("Game repository draft is unavailable"); + } + draft.commit(); + }) + .whenComplete(exception -> { + GameRepositoryDraft draft = activeDraft.getAndSet(null); + if (draft != null && draft.isOpen()) { + draft.abort(); + } + }) .whenComplete(Schedulers.javafx(), (exception) -> { if (exception == null) { repository.setSelectedInstance(repository.getInstance(instanceId)); 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 1038d918f7b..c2db4cc5fb8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -25,15 +25,16 @@ import org.jackhuang.hmcl.util.function.ExceptionalFunction; import org.jetbrains.annotations.NotNullByDefault; -import java.io.IOException; import java.util.ArrayList; import java.util.Map; import java.util.Objects; +import java.util.concurrent.atomic.AtomicReference; -/// Builds a new game instance by registering a placeholder through [GameRepositoryDraft], installing -/// components against that instance, then saving the completed manifest. +/// Builds a new game instance in an exclusive [GameRepositoryDraft], installs its components, and +/// publishes the completed instance once. /// -/// On failure after the placeholder is committed, [#buildAsync()] removes the instance directory. +/// Shared libraries, assets, and download caches may remain after failure. The draft removes the +/// instance directory that it created and never publishes a placeholder instance. @NotNullByDefault public class DefaultGameBuilder extends GameBuilder { @@ -56,10 +57,8 @@ public DefaultDependencyManager getDependencyManager() { /// {@inheritDoc} /// - /// Registers [#name] via a draft commit, installs the configured game and optional loaders into - /// that instance, then [DefaultGameRepository#saveAsync(GameInstanceManifest)] the final - /// manifest. If any step fails after the placeholder is published, the instance is removed from - /// disk. + /// Creates an unpublished working instance, installs the configured game and optional loaders, + /// stages the completed manifest, and commits it once. Failure or cancellation aborts the draft. /// /// @return the build task /// @throws NullPointerException if [#name] was not set @@ -83,23 +82,15 @@ public Task buildAsync() { } DefaultGameRepository repository = dependencyManager.getGameRepository(); + AtomicReference activeDraft = new AtomicReference<>(); return Task.supplyAsync(() -> { GameRepositoryDraft draft = repository.openDraft(); - try { - draft.put(new GameInstanceManifest(name)); - draft.commit(); - return repository.getInstance(name); - } catch (IOException e) { - draft.abort(); - throw e; - } catch (RuntimeException e) { - draft.abort(); - throw e; - } + activeDraft.set(draft); + return draft.put(new GameInstanceManifest(name)); }) .thenComposeAsync(instance -> { - Task libraryTask = Task.completed(instance.getManifest()); + Task libraryTask = Task.supplyAsync(instance::getManifest); libraryTask = libraryTask.thenComposeAsync( libraryTaskHelper(instance, gameVersion, "game", gameVersion)); @@ -113,11 +104,19 @@ public Task buildAsync() { dependencyManager.installComponentAsync(instance, working, remoteVersion)); } - return libraryTask.thenComposeAsync(repository::saveAsync); + return libraryTask.thenApplyAsync(manifest -> { + GameRepositoryDraft draft = activeDraft.get(); + if (draft == null) { + throw new IllegalStateException("Game repository draft is unavailable"); + } + draft.put(manifest); + return draft.commit().getInstance(name); + }); }) .whenComplete(exception -> { - if (exception != null) { - dependencyManager.getGameRepository().removeInstanceFromDisk(name); + GameRepositoryDraft draft = activeDraft.getAndSet(null); + if (draft != null && draft.isOpen()) { + draft.abort(); } }) .withStagesHints(hints); 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 ec2fda7f264..a7bea9e7fb6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java @@ -18,50 +18,71 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.DefaultDependencyManager; -import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; +/// Downloads the base game component and returns its manifest patch without publishing it. +/// +/// Game files, libraries, and assets are downloaded as dependencies. The caller owns the working +/// manifest and must stage the returned patch in its repository draft. +@NotNullByDefault public class GameInstallTask extends Task { - private final DefaultGameRepository gameRepository; + /// Dependency manager used by the download tasks. private final DefaultDependencyManager dependencyManager; + + /// Working instance manifest that will receive the game patch. private final GameInstanceManifest manifest; + + /// Selected remote game version. private final GameRemoteVersion remote; + + /// Task that downloads the selected version's manifest JSON. private final GameInstanceJsonDownloadTask downloadTask; + + /// Downloads scheduled after the remote manifest has been decoded. private final List> dependencies = new ArrayList<>(1); + /// Creates a base-game installation task. + /// + /// @param dependencyManager the dependency manager for the target repository + /// @param manifest the working instance manifest + /// @param remoteVersion the selected remote game version public GameInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, GameRemoteVersion remoteVersion) { this.dependencyManager = dependencyManager; - this.gameRepository = dependencyManager.getGameRepository(); this.manifest = manifest; this.remote = remoteVersion; this.downloadTask = new GameInstanceJsonDownloadTask(remoteVersion.getGameVersion(), dependencyManager); } + /// {@inheritDoc} @Override public Collection> getDependents() { return Collections.singleton(downloadTask); } + /// {@inheritDoc} @Override public Collection> getDependencies() { return dependencies; } + /// {@inheritDoc} @Override public boolean isRelyingOnDependencies() { return false; } + /// Decodes the downloaded game manifest and schedules its files without saving repository state. @Override public void execute() throws Exception { GameInstancePatch patch = GameInstancePatch.fromManifest( @@ -80,7 +101,7 @@ public void execute() throws Exception { ).withRunAsync(() -> { // ignore failure }) - ).thenComposeAsync(gameRepository.saveAsync(newManifest))); + )); } } 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 d821e06e878..0aa0704517b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -24,6 +24,7 @@ import javafx.beans.property.SimpleObjectProperty; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.Lang; +import org.jackhuang.hmcl.util.function.ExceptionalFunction; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -32,7 +33,6 @@ import java.io.IOException; import java.nio.file.Files; -import java.nio.file.InvalidPathException; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.util.*; @@ -40,6 +40,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -94,6 +95,15 @@ private static boolean hasClassicInstance(Path baseDirectory) { /// Published snapshot, always updated on the JavaFX application thread when the toolkit is live. private final ObjectProperty snapshot; + /// Monitor guarding the exclusive draft and direct repository write state. + private final Object writeSessionMonitor = new Object(); + + /// The repository's sole open draft, or `null` when no draft is active. + private @Nullable DefaultGameRepositoryDraft activeDraft; + + /// Number of refresh, layout-replacement, or orphan-cleanup writes currently in progress. + private int activeDirectWrites; + /// Whether at least one full refresh has completed since the base directory was set. private volatile boolean loaded; @@ -112,17 +122,50 @@ 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 after the draft has recorded ownership, so failure cleanup will remove + /// the root. The default implementation has no additional data to materialize. + /// + /// @param instanceId the instance being created + /// @param instanceRoot the normalized instance root owned by the draft + /// @throws IOException if prepared data cannot be written + protected void initializeDraftInstanceRoot(GameInstanceID instanceId, Path instanceRoot) throws IOException { + } + + /// Replaces the repository layout with an empty snapshot rooted at `baseDirectory`. + /// + /// @param baseDirectory the new repository base directory + /// @throws IllegalStateException if a draft is active public void setBaseDirectory(Path baseDirectory) { - // Mark unloaded before publishing so snapshot listeners do not treat the empty snapshot as ready. - this.loaded = false; - DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); - publishSnapshot(initial); + beginDirectWrite("set base directory"); + try { + // Mark unloaded before publishing so snapshot listeners do not treat the empty snapshot as ready. + this.loaded = false; + DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); + publishSnapshot(initial); + } finally { + endDirectWrite("set base directory"); + } } /// {@inheritDoc} /// - /// The returned snapshot is sealed and must not be modified. Writers must [#clone()] it, edit the - /// copy, and publish the result with [#publishSnapshot(DefaultGameRepositorySnapshot)]. + /// The returned snapshot is sealed and must not be modified. Normal writers must use + /// [#openDraft()]; refresh and layout replacement use the internal publication path. @Override public DefaultGameRepositorySnapshot getSnapshot() { return snapshot.get(); @@ -140,6 +183,9 @@ public ReadOnlyObjectProperty snapshotP /// Seals `newSnapshot` if needed and publishes it as the current repository snapshot. /// + /// This is the low-level publication mechanism for draft commit, refresh, and layout + /// replacement. Other repository writes must use [#openDraft()]. + /// /// When the JavaFX toolkit is running, the property is updated on the JavaFX application thread /// (blocking the caller if publish happens off the FX thread) so that listeners run on FX and /// [#getSnapshot()] observes the new value before this method returns. @@ -154,6 +200,43 @@ protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { }); } + /// Publishes the working snapshot of the repository's active draft. + /// + /// @param draft the active draft + /// @param newSnapshot the draft's working snapshot + /// @throws IllegalStateException if `draft` is not the active draft + void publishDraftSnapshot( + DefaultGameRepositoryDraft draft, + DefaultGameRepositorySnapshot newSnapshot) { + checkActiveDraft(draft); + publishSnapshot(newSnapshot); + } + + /// Verifies that `draft` owns this repository's exclusive write session. + /// + /// @param draft the draft to verify + /// @throws IllegalStateException if `draft` is not active + void checkActiveDraft(DefaultGameRepositoryDraft draft) { + synchronized (writeSessionMonitor) { + if (activeDraft != draft) { + throw new IllegalStateException("Draft is not the active repository draft"); + } + } + } + + /// Releases the exclusive write session owned by `draft`. + /// + /// @param draft the draft that completed, aborted, or failed + /// @throws IllegalStateException if `draft` is not active + void releaseDraft(DefaultGameRepositoryDraft draft) { + synchronized (writeSessionMonitor) { + if (activeDraft != draft) { + throw new IllegalStateException("Draft is not the active repository draft"); + } + activeDraft = null; + } + } + /// Runs an action on the JavaFX application thread and waits for its completion. /// /// The action runs on the calling thread when the JavaFX toolkit has not been initialized. @@ -204,8 +287,21 @@ public boolean isLoaded() { return loaded; } + /// {@inheritDoc} + /// + /// @throws IllegalStateException if a draft is active @Override public void refresh() { + beginDirectWrite("refresh"); + try { + refreshRepository(); + } finally { + endDirectWrite("refresh"); + } + } + + /// Reloads and publishes repository state while the caller owns the direct-write session. + private void refreshRepository() { DefaultGameRepositorySnapshot newSnapshot = createSnapshot(getSnapshot().getLayout()); DefaultGameRepositoryLayout layout = newSnapshot.getLayout(); @@ -309,7 +405,7 @@ private static GameInstanceManifest readInstanceManifest(Path json) throws IOExc return manifest; } - private static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, GameInstanceID to) throws IOException { + static void moveInstanceFiles(Path baseDirectory, GameInstanceID from, GameInstanceID to) throws IOException { Path instancesDir = baseDirectory.resolve("versions"); Path fromDir = instancesDir.resolve(from.id()); Path toDir = instancesDir.resolve(to.id()); @@ -377,39 +473,11 @@ public Path getInstanceJar(GameInstanceManifest manifest) { @Override public boolean renameInstance(GameInstanceID from, GameInstanceID to) { - try { - DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); - DefaultGameInstance fromHolder = newSnapshot.get(from); - if (fromHolder == null) { - throw new NoSuchGameInstanceException(from); - } - - moveInstanceFiles(newSnapshot.getLayout().getBaseDirectory(), from, to); - - GameInstanceManifest renamedManifest = fromHolder.manifest; - if (from.equals(renamedManifest.jar())) { - renamedManifest = renamedManifest.withJar(null); - } - renamedManifest = renamedManifest.withId(to); - JsonUtils.writeToJsonFile(getInstanceJson(to), renamedManifest); - - newSnapshot.remove(from); - newSnapshot.put(fromHolder.withManifest(newSnapshot, renamedManifest)); - - for (DefaultGameInstance instance : List.copyOf(newSnapshot.values())) { - GameInstanceManifest manifest = instance.manifest; - if (from.equals(manifest.inheritsFrom())) { - GameInstanceManifest updatedManifest = manifest.withInheritsFrom(to); - Path targetPath = getInstanceJson(updatedManifest.id()); - Files.createDirectories(targetPath.getParent()); - JsonUtils.writeToJsonFile(targetPath, updatedManifest); - newSnapshot.put(instance.withManifest(newSnapshot, updatedManifest)); - } - } - - publishSnapshot(newSnapshot); + try (DefaultGameRepositoryDraft draft = openDraft()) { + draft.rename(from, to); + draft.commit(); return true; - } catch (IOException | JsonParseException | NoSuchGameInstanceException | InvalidPathException e) { + } catch (IOException | JsonParseException | NoSuchGameInstanceException | IllegalArgumentException e) { LOG.warning("Unable to rename instance " + from + " to " + to, e); return false; } @@ -417,21 +485,27 @@ public boolean renameInstance(GameInstanceID from, GameInstanceID to) { /// Removes an instance from the published index and attempts to remove its backing directory. /// - /// The repository is refreshed before this method returns, including when filesystem removal - /// fails after the instance has been removed from the published snapshot. After the instance - /// directory is staged under its `_removed` sibling, failure to trash or fully delete that - /// staging directory is logged but does not change the return value. + /// Registered instances are removed through an exclusive draft: their roots are first moved to + /// draft-private storage, the new snapshot is published once, and the staged roots are then + /// deleted. An unregistered orphan directory uses the legacy trash-or-delete cleanup path and is + /// followed by a repository refresh. /// /// @param id the instance id /// @return `false` if removal is denied or the instance directory cannot be staged; `true` if /// the directory is absent or staging succeeds public boolean removeInstanceFromDisk(GameInstanceID id) { if (getSnapshot().get(id) != null) { - DefaultGameRepositorySnapshot newSnapshot = getSnapshot().clone(); - newSnapshot.remove(id); - publishSnapshot(newSnapshot); + try (DefaultGameRepositoryDraft draft = openDraft()) { + draft.remove(id); + draft.commit(); + return true; + } catch (IOException e) { + LOG.warning("Unable to remove instance " + id, e); + return false; + } } + beginDirectWrite("remove instance"); try { Path file = getLayout().getInstanceRoot(id); if (Files.notExists(file)) { @@ -465,7 +539,11 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { } return true; } finally { - refresh(); + try { + refreshRepository(); + } finally { + endDirectWrite("remove instance"); + } } } @@ -515,7 +593,18 @@ public Path getInstanceJson(GameInstanceID instanceId) { /// @return a new open draft @Override public DefaultGameRepositoryDraft openDraft() { - return new DefaultGameRepositoryDraft(this); + synchronized (writeSessionMonitor) { + if (activeDraft != null) { + throw new IllegalStateException("Another repository draft is already open"); + } + if (activeDirectWrites != 0) { + throw new IllegalStateException("Repository is currently performing a direct write"); + } + + DefaultGameRepositoryDraft draft = new DefaultGameRepositoryDraft(this); + activeDraft = draft; + return draft; + } } /// Writes a stored manifest and publishes a new snapshot in a single draft commit. @@ -542,6 +631,47 @@ public Task saveAsync(GameInstanceManifest instanceManifes return Task.supplyAsync(() -> save(instanceManifest)); } + /// Creates a task that updates one registered instance inside an exclusive draft. + /// + /// The updater receives the instance from the draft's unpublished working snapshot and must + /// return a manifest with the same id. Its result is staged and committed exactly once. Failure + /// or cancellation aborts the draft; shared cache files written by the updater are retained. + /// + /// @param the checked exception type thrown while creating the update task + /// @param instanceId the instance to update + /// @param updater the asynchronous manifest update + /// @return the task that commits the updated manifest + public Task updateInstanceAsync( + GameInstanceID instanceId, + ExceptionalFunction, E> updater) { + AtomicReference active = new AtomicReference<>(); + return Task.supplyAsync(() -> { + GameRepositoryDraft draft = openDraft(); + active.set(draft); + return draft.getSnapshot().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(); + } + }); + } + @Override public GameInstanceManifest.Resolved resolve(GameInstanceManifest manifest) throws NoSuchGameInstanceException { return getSnapshot().resolve(manifest); @@ -581,4 +711,30 @@ protected abstract DefaultGameInstance createInstance( GameInstanceManifest manifest, @Nullable Path manifestFile); + /// Begins a direct repository write that must not overlap a draft. + /// + /// @param operation human-readable operation name used in diagnostics + /// @throws IllegalStateException if a draft is active + private void beginDirectWrite(String operation) { + synchronized (writeSessionMonitor) { + if (activeDraft != null) { + throw new IllegalStateException("Repository has an open draft; cannot " + operation); + } + activeDirectWrites++; + } + } + + /// Ends a direct repository write. + /// + /// @param operation the operation name passed to [#beginDirectWrite(String)] + /// @throws IllegalStateException if no direct write is active + private void endDirectWrite(String operation) { + synchronized (writeSessionMonitor) { + if (activeDirectWrites == 0) { + throw new IllegalStateException("Direct repository write is not active: " + operation); + } + activeDirectWrites--; + } + } + } 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 9fec4dd8032..40cefd8eefe 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -23,57 +23,64 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; import java.nio.file.Files; import java.nio.file.Path; -import java.util.HashMap; -import java.util.HashSet; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/// Default [GameRepositoryDraft] that keeps the published snapshot immutable and stages stored -/// manifests until [#commit()]. +/// Default exclusive [GameRepositoryDraft] implementation. /// -/// The draft holds [#base] (the repository snapshot at open time) and [#staged] manifests. -/// [#put(GameInstanceManifest)] only updates that map and instance JSON. [#commit()] clones -/// [#base] once, applies staged manifests, and publishes the result. No [GameInstance] is produced -/// by the draft itself. +/// Manifest changes are reflected in an unpublished working snapshot and serialized below a +/// draft-private directory. Instance installers may use the returned working [GameInstance] and +/// write instance-owned files before commit. A successful commit moves all staged manifests into +/// place and publishes the working snapshot once. Shared library and asset cache writes are outside +/// the rollback boundary. @NotNullByDefault public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { - /// Repository whose published index will be replaced on [#commit()]. + /// Repository whose published snapshot will be replaced on commit. private final DefaultGameRepository repository; /// Immutable published snapshot captured when this draft was opened. - private final DefaultGameRepositorySnapshot base; + private final DefaultGameRepositorySnapshot baseSnapshot; - /// Staged stored manifests keyed by instance id; applied only on [#commit()]. - private final Map staged = new HashMap<>(); + /// Mutable snapshot containing the draft's unpublished manifest changes. + private final DefaultGameRepositorySnapshot workingSnapshot; - /// Instance ids that were not present in [#base] when first staged. - private final Set createdIds = new HashSet<>(); + /// Staged manifest files keyed by instance id. + private final Map stagedManifests = new TreeMap<>(); - /// Base stored manifests for ids that existed in [#base] and were later staged. - /// - /// Used by [#abort()] to restore on-disk JSON. - private final Map originalManifests = new HashMap<>(); + /// Instance ids whose root directories were absent before this draft first staged them. + private final Set createdIds = new TreeSet<>(); + + /// Instance ids removed from the working snapshot. + private final Set removedIds = new TreeSet<>(); - /// Snapshot published by [#commit()], or `null` before a successful commit. - private @Nullable DefaultGameRepositorySnapshot committedSnapshot; + /// Ordered instance renames applied to the filesystem during commit. + private final List renames = new ArrayList<>(); - /// Whether [#commit()] has completed successfully. - private boolean committed; + /// Draft-private directory containing staged manifests and commit backups. + private @Nullable Path stagingDirectory; - /// Whether this draft no longer accepts mutations. - private boolean closed; + /// Current lifecycle state. + private GameRepositoryDraftState state = GameRepositoryDraftState.OPEN; - /// Creates a draft over `repository`'s current published snapshot as an immutable base. + /// Creates an open draft over the repository's current published snapshot. /// /// @param repository the repository that owns this draft DefaultGameRepositoryDraft(DefaultGameRepository repository) { this.repository = repository; - this.base = repository.getSnapshot(); + this.baseSnapshot = repository.getSnapshot(); + this.workingSnapshot = baseSnapshot.clone(); } /// {@inheritDoc} @@ -83,160 +90,547 @@ public DefaultGameRepository getRepository() { } /// {@inheritDoc} - /// - /// Returns [#base] while open, or the snapshot published by [#commit()] after success. - /// - /// @throws IllegalStateException if the draft was aborted or closed without commit @Override - public DefaultGameRepositorySnapshot getSnapshot() { - if (committed) { - return committedSnapshot; - } - if (closed) { - throw new IllegalStateException("Draft is closed"); + public GameRepositorySnapshot getBaseSnapshot() { + return baseSnapshot; + } + + /// {@inheritDoc} + @Override + public synchronized GameRepositorySnapshot getSnapshot() { + if (state == GameRepositoryDraftState.ABORTED || state == GameRepositoryDraftState.FAILED) { + throw new IllegalStateException("Draft is " + state.name().toLowerCase()); } - return base; + return workingSnapshot; } /// {@inheritDoc} @Override - public boolean isOpen() { - return !closed; + public synchronized GameRepositoryDraftState getState() { + return state; } /// {@inheritDoc} @Override - public boolean isCommitted() { - return committed; + public synchronized boolean isOpen() { + return state == GameRepositoryDraftState.OPEN; } /// {@inheritDoc} @Override - public boolean hasInstance(GameInstanceID instanceId) { + public synchronized boolean isCommitted() { + return state == GameRepositoryDraftState.COMMITTED; + } + + /// {@inheritDoc} + @Override + public synchronized boolean hasInstance(GameInstanceID instanceId) { checkOpen(); - return staged.containsKey(instanceId) || base.hasInstance(instanceId); + return workingSnapshot.hasInstance(instanceId); + } + + /// {@inheritDoc} + @Override + public synchronized DefaultGameInstance put(GameInstanceManifest manifest) throws IOException { + checkOpen(); + return stageManifest(manifest, true); + } + + /// {@inheritDoc} + @Override + public synchronized void remove(GameInstanceID instanceId) { + checkOpen(); + if (workingSnapshot.get(instanceId) == null) { + throw new NoSuchGameInstanceException(instanceId); + } + + workingSnapshot.remove(instanceId); + stagedManifests.remove(instanceId); + removedIds.add(instanceId); } /// {@inheritDoc} - /// - /// Writes `manifest` to disk and records it in [#staged]. Does not modify [#base] and does not - /// create a [GameInstance]. - /// - /// @throws IllegalStateException if the draft is closed @Override - public void put(GameInstanceManifest manifest) throws IOException { + public synchronized void rename(GameInstanceID from, GameInstanceID to) throws IOException { checkOpen(); + DefaultGameInstance source = workingSnapshot.get(from); + if (source == null) { + throw new NoSuchGameInstanceException(from); + } + if (createdIds.contains(from)) { + throw new IllegalStateException("Cannot rename an instance created by the same draft"); + } + if (workingSnapshot.get(to) != null) { + throw new IllegalArgumentException("Target instance already exists: " + to); + } + + Path targetRoot = getValidatedInstanceRoot(to); + if (Files.exists(targetRoot)) { + throw new FileAlreadyExistsException(targetRoot.toString()); + } + + GameInstanceManifest renamedManifest = source.getManifest(); + if (from.equals(renamedManifest.jar())) { + renamedManifest = renamedManifest.withJar(null); + } + renamedManifest = renamedManifest.withId(to); + + workingSnapshot.remove(from); + stagedManifests.remove(from); + removedIds.remove(from); + DefaultGameInstance renamed = repository.createInstance(workingSnapshot, to, renamedManifest); + workingSnapshot.put(renamed); + stageManifest(renamedManifest, false); + + for (DefaultGameInstance instance : List.copyOf(workingSnapshot.values())) { + GameInstanceManifest manifest = instance.getManifest(); + if (from.equals(manifest.inheritsFrom())) { + stageManifest(manifest.withInheritsFrom(to), false); + } + } + renames.add(new RenameOperation(from, to)); + } + + /// Stages one manifest and updates the working snapshot. + /// + /// @param manifest the manifest to stage + /// @param claimNewRoot whether a previously absent instance root should become draft-owned + /// @return the updated working instance + /// @throws IOException if the root cannot be claimed or the temporary manifest cannot be written + private DefaultGameInstance stageManifest( + GameInstanceManifest manifest, + boolean claimNewRoot) throws IOException { GameInstanceID id = manifest.id(); - DefaultGameInstance existingInBase = base.get(id); - if (existingInBase != null) { - originalManifests.putIfAbsent(id, existingInBase.getManifest()); - } else if (!staged.containsKey(id)) { + DefaultGameInstance existing = workingSnapshot.get(id); + if (claimNewRoot && existing == null && !stagedManifests.containsKey(id)) { + Path root = getValidatedInstanceRoot(id); + if (!repository.mayClaimDraftInstanceRoot(id, root)) { + throw new FileAlreadyExistsException(root.toString(), null, + "An unregistered instance directory already exists"); + } createdIds.add(id); + repository.initializeDraftInstanceRoot(id, root); } - Path json = repository.getInstanceJson(id).toAbsolutePath(); - Files.createDirectories(json.getParent()); - JsonUtils.writeToJsonFile(json, manifest); + StagedManifest previous = stagedManifests.get(id); + Path targetFile = previous != null ? previous.targetFile() : getManifestTarget(id); + Path stagedFile = previous != null ? previous.stagedFile() : createStagedManifestPath(); + FileUtils.saveSafely(stagedFile, JsonUtils.GSON.toJson(manifest)); + stagedManifests.put(id, new StagedManifest(stagedFile, targetFile)); - staged.put(id, manifest); + DefaultGameInstance updated; + if (existing != null) { + updated = existing.withManifest(workingSnapshot, manifest); + } else { + updated = repository.createInstance(workingSnapshot, id, manifest); + } + workingSnapshot.put(updated); + return updated; } /// {@inheritDoc} - /// - /// Clones [#base], applies all staged manifests onto the clone, seals it, and publishes it. - /// - /// @throws IllegalStateException if the draft is closed or already committed @Override - public void commit() { + public synchronized DefaultGameRepositorySnapshot commit() throws IOException { checkOpen(); - if (committed) { - throw new IllegalStateException("Draft already committed"); - } - - DefaultGameRepositorySnapshot next = base.clone(); - for (Map.Entry entry : staged.entrySet()) { - GameInstanceID id = entry.getKey(); - GameInstanceManifest manifest = entry.getValue(); - DefaultGameInstance existing = next.get(id); - if (existing != null) { - next.put(existing.withManifest(next, manifest)); - } else { - next.put(repository.createInstance(next, id, manifest)); + repository.checkActiveDraft(this); + state = GameRepositoryDraftState.COMMITTING; + + List appliedRenames = new ArrayList<>(); + List removedRoots = new ArrayList<>(); + List applied = new ArrayList<>(); + try { + for (RenameOperation rename : renames) { + applyRename(rename, appliedRenames); + } + for (GameInstanceID id : removedIds) { + removeInstanceRoot(id, removedRoots); + } + for (Map.Entry entry : stagedManifests.entrySet()) { + applyManifest(entry.getKey(), entry.getValue(), applied); } + + repository.publishDraftSnapshot(this, workingSnapshot); + state = GameRepositoryDraftState.COMMITTED; + repository.releaseDraft(this); + cleanupStagingAfterCommit(); + return workingSnapshot; + } catch (IOException | RuntimeException e) { + IOException rollbackFailure = rollbackAppliedManifests(applied); + rollbackFailure = accumulateNullable(rollbackFailure, rollbackRemovedRoots(removedRoots)); + rollbackFailure = accumulateNullable(rollbackFailure, rollbackRenames(appliedRenames)); + state = GameRepositoryDraftState.FAILED; + repository.releaseDraft(this); + IOException cleanupFailure = cleanupOwnedFiles(); + if (rollbackFailure != null) { + e.addSuppressed(rollbackFailure); + } + if (cleanupFailure != null) { + e.addSuppressed(cleanupFailure); + } + throw e; + } + } + + /// {@inheritDoc} + @Override + public synchronized void abort() throws IOException { + if (state == GameRepositoryDraftState.ABORTED) { + return; + } + if (state == GameRepositoryDraftState.COMMITTED) { + throw new IllegalStateException("Draft is already committed"); + } + if (state == GameRepositoryDraftState.COMMITTING) { + throw new IllegalStateException("Draft is committing"); + } + if (state == GameRepositoryDraftState.FAILED) { + return; } - repository.publishSnapshot(next); - committedSnapshot = next; - committed = true; - closed = true; + IOException failure = cleanupOwnedFiles(); + state = failure == null ? GameRepositoryDraftState.ABORTED : GameRepositoryDraftState.FAILED; + repository.releaseDraft(this); + if (failure != null) { + throw failure; + } } /// {@inheritDoc} + @Override + public synchronized void close() throws IOException { + if (state == GameRepositoryDraftState.OPEN) { + abort(); + } + } + + /// Returns the permanent manifest target for an instance. /// - /// Idempotent when already aborted. Does not publish a snapshot. + /// Existing instances retain a non-conventional manifest path discovered by refresh. New + /// instances use the conventional path from the base layout. /// - /// @throws IllegalStateException if the draft was already committed - @Override - public void abort() { - if (committed) { - throw new IllegalStateException("Draft already committed"); + /// @param id the instance id + /// @return the permanent manifest path + private Path getManifestTarget(GameInstanceID id) { + DefaultGameInstance existing = baseSnapshot.get(id); + return (existing != null ? existing.getManifestFile() : baseSnapshot.getLayout().getInstanceJson(id)) + .toAbsolutePath() + .normalize(); + } + + /// Creates a unique path for a staged manifest. + /// + /// @return the staged manifest path + /// @throws IOException if the staging directory cannot be created + private Path createStagedManifestPath() throws IOException { + Path manifests = getOrCreateStagingDirectory().resolve("manifests"); + Files.createDirectories(manifests); + return Files.createTempFile(manifests, "manifest-", ".json"); + } + + /// Returns the draft-private staging directory, creating it when necessary. + /// + /// @return the staging directory + /// @throws IOException if the directory cannot be created + private Path getOrCreateStagingDirectory() throws IOException { + Path current = stagingDirectory; + if (current != null) { + return current; + } + + Path parent = baseSnapshot.getLayout().getBaseDirectory() + .toAbsolutePath() + .normalize() + .resolve(".hmcl") + .resolve("repository-drafts"); + Files.createDirectories(parent); + stagingDirectory = Files.createTempDirectory(parent, "draft-"); + return stagingDirectory; + } + + /// Applies one instance directory rename. + /// + /// @param rename the requested rename + /// @param applied rollback records for completed renames + /// @throws IOException if the source files cannot be renamed + private void applyRename(RenameOperation rename, List applied) throws IOException { + Path sourceRoot = getValidatedInstanceRoot(rename.from()); + Path targetRoot = getValidatedInstanceRoot(rename.to()); + if (!Files.isDirectory(sourceRoot)) { + throw new IOException("Instance directory does not exist: " + sourceRoot); } - if (closed) { + if (Files.exists(targetRoot)) { + throw new FileAlreadyExistsException(targetRoot.toString()); + } + + DefaultGameRepository.moveInstanceFiles( + baseSnapshot.getLayout().getBaseDirectory(), + rename.from(), + rename.to()); + applied.add(new AppliedRename(rename.from(), rename.to())); + } + + /// Moves one removed instance root into the draft staging directory. + /// + /// @param id the removed instance id + /// @param removed rollback records for roots moved out of the repository + /// @throws IOException if the root cannot be staged + private void removeInstanceRoot(GameInstanceID id, List removed) throws IOException { + Path root = getValidatedInstanceRoot(id); + if (Files.notExists(root)) { return; } - closed = true; - for (GameInstanceID id : createdIds) { + Path removals = getOrCreateStagingDirectory().resolve("removed"); + Files.createDirectories(removals); + Path stagedRoot = Files.createTempDirectory(removals, "instance-"); + Files.delete(stagedRoot); + moveReplacing(root, stagedRoot); + removed.add(new RemovedRoot(root, stagedRoot)); + } + + /// Moves one staged manifest into place while retaining a rollback copy. + /// + /// @param id the instance id + /// @param staged the staged and target paths + /// @param applied rollback records for changes already started + /// @throws IOException if the target cannot be backed up or replaced + private void applyManifest( + GameInstanceID id, + StagedManifest staged, + List applied) throws IOException { + Path target = staged.targetFile(); + Path expectedRoot = getValidatedInstanceRoot(id); + if (target.equals(expectedRoot) || !target.startsWith(expectedRoot)) { + throw new IOException("Manifest path escapes instance root: " + target); + } + + Files.createDirectories(target.getParent()); + boolean hadOriginal = Files.exists(target); + @Nullable Path backup = null; + if (hadOriginal) { + Path backups = getOrCreateStagingDirectory().resolve("backups"); + Files.createDirectories(backups); + backup = Files.createTempFile(backups, "manifest-", ".json"); + Files.delete(backup); + moveReplacing(target, backup); + } + + applied.add(new AppliedManifest(target, backup, hadOriginal)); + moveReplacing(staged.stagedFile(), target); + } + + /// Restores manifests changed by an unsuccessful commit in reverse application order. + /// + /// @param applied applied manifest records + /// @return the aggregated rollback failure, or `null` when rollback succeeded + private static @Nullable IOException rollbackAppliedManifests(List applied) { + @Nullable IOException failure = null; + List reversed = new ArrayList<>(applied); + Collections.reverse(reversed); + for (AppliedManifest manifest : reversed) { try { - deleteInstanceDirectory(id); - } catch (Exception e) { - LOG.warning("Failed to remove draft-created instance " + id, e); + Files.deleteIfExists(manifest.targetFile()); + if (manifest.hadOriginal() && manifest.backupFile() != null) { + moveReplacing(manifest.backupFile(), manifest.targetFile()); + } + } catch (IOException e) { + failure = accumulate(failure, e); } } + return failure; + } - for (Map.Entry entry : originalManifests.entrySet()) { - if (createdIds.contains(entry.getKey())) { - continue; - } + /// Restores roots moved out of the repository by an unsuccessful commit. + /// + /// @param removed removed-root rollback records + /// @return the aggregated rollback failure, or `null` when rollback succeeded + private static @Nullable IOException rollbackRemovedRoots(List removed) { + @Nullable IOException failure = null; + List reversed = new ArrayList<>(removed); + Collections.reverse(reversed); + for (RemovedRoot root : reversed) { try { - Path json = repository.getInstanceJson(entry.getKey()).toAbsolutePath(); - Files.createDirectories(json.getParent()); - JsonUtils.writeToJsonFile(json, entry.getValue()); + moveReplacing(root.stagedRoot(), root.originalRoot()); } catch (IOException e) { - LOG.warning("Failed to restore manifest for " + entry.getKey(), e); + failure = accumulate(failure, e); } } + return failure; } - /// {@inheritDoc} + /// Reverses instance renames completed by an unsuccessful commit. /// - /// Calls [#abort()] when the draft is still open and not committed. - @Override - public void close() { - if (!closed && !committed) { - abort(); + /// @param applied completed rename records + /// @return the aggregated rollback failure, or `null` when rollback succeeded + private @Nullable IOException rollbackRenames(List applied) { + @Nullable IOException failure = null; + List reversed = new ArrayList<>(applied); + Collections.reverse(reversed); + for (AppliedRename rename : reversed) { + try { + DefaultGameRepository.moveInstanceFiles( + baseSnapshot.getLayout().getBaseDirectory(), + rename.to(), + rename.from()); + } catch (IOException e) { + failure = accumulate(failure, e); + } } + return failure; } - /// Deletes a draft-created instance directory without modifying the published snapshot. + /// Removes draft-owned instance roots and temporary files. /// - /// @param id the instance id whose root directory will be removed - /// @throws IOException if deletion fails - private void deleteInstanceDirectory(GameInstanceID id) throws IOException { - Path root = repository.getLayout().getInstanceRoot(id); - if (Files.notExists(root)) { + /// @return the aggregated cleanup failure, or `null` when cleanup succeeded + private @Nullable IOException cleanupOwnedFiles() { + @Nullable IOException failure = null; + for (GameInstanceID id : createdIds) { + try { + Path root = getValidatedInstanceRoot(id); + if (Files.exists(root)) { + FileUtils.deleteDirectory(root); + } + } catch (IOException | RuntimeException e) { + IOException cleanupException = e instanceof IOException ioException + ? ioException + : new IOException("Failed to remove draft-created instance " + id, e); + failure = accumulate(failure, cleanupException); + } + } + + Path currentStagingDirectory = stagingDirectory; + if (currentStagingDirectory != null) { + try { + FileUtils.deleteDirectory(currentStagingDirectory); + } catch (IOException e) { + failure = accumulate(failure, e); + } + } + return failure; + } + + /// Removes temporary files after a successful commit without changing its outcome. + private void cleanupStagingAfterCommit() { + Path currentStagingDirectory = stagingDirectory; + if (currentStagingDirectory == null) { return; } - FileUtils.deleteDirectory(root); + try { + FileUtils.deleteDirectory(currentStagingDirectory); + } catch (IOException e) { + LOG.warning("Failed to remove committed draft staging directory " + currentStagingDirectory, e); + } + } + + /// Returns a normalized instance root after verifying that it is a strict descendant of the + /// repository's versions directory. + /// + /// @param id the instance id + /// @return the validated instance root + /// @throws IOException if the resolved root escapes the versions directory + private Path getValidatedInstanceRoot(GameInstanceID id) throws IOException { + Path versions = baseSnapshot.getLayout().getBaseDirectory() + .toAbsolutePath() + .normalize() + .resolve("versions"); + Path root = baseSnapshot.getLayout().getInstanceRoot(id).toAbsolutePath().normalize(); + if (root.equals(versions) || !root.startsWith(versions)) { + throw new IOException("Instance root escapes versions directory: " + root); + } + return root; + } + + /// Moves a file to `target`, using an atomic move when supported by the file system. + /// + /// @param source the source file + /// @param target the target file + /// @throws IOException if both atomic and regular replacement fail + private static void moveReplacing(Path source, Path target) throws IOException { + try { + Files.move(source, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException atomicFailure) { + try { + Files.move(source, target, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException replacementFailure) { + replacementFailure.addSuppressed(atomicFailure); + throw replacementFailure; + } + } + } + + /// Aggregates an additional cleanup failure. + /// + /// @param current the current aggregate, or `null` + /// @param additional the additional failure + /// @return the resulting aggregate + private static IOException accumulate(@Nullable IOException current, IOException additional) { + if (current == null) { + return additional; + } + current.addSuppressed(additional); + return current; } - /// Ensures the draft still accepts mutations. + /// Combines two optional failure aggregates. /// - /// @throws IllegalStateException if the draft is closed + /// @param current the current aggregate, or `null` + /// @param additional the additional aggregate, or `null` + /// @return the combined aggregate, or `null` when both arguments are `null` + private static @Nullable IOException accumulateNullable( + @Nullable IOException current, + @Nullable IOException additional) { + if (additional == null) { + return current; + } + return accumulate(current, additional); + } + + /// Ensures the draft accepts changes. + /// + /// @throws IllegalStateException if the draft is not open private void checkOpen() { - if (closed) { - throw new IllegalStateException("Draft is closed"); + if (state != GameRepositoryDraftState.OPEN) { + throw new IllegalStateException("Draft is " + state.name().toLowerCase()); } } + + /// Records the temporary and permanent paths for one staged manifest. + /// + /// @param stagedFile the draft-private serialized manifest + /// @param targetFile the permanent repository manifest path + private record StagedManifest(Path stagedFile, Path targetFile) { + } + + /// Records enough information to roll back one manifest replacement. + /// + /// @param targetFile the permanent manifest path + /// @param backupFile the prior manifest backup, or `null` when no prior file existed + /// @param hadOriginal whether the permanent manifest existed before commit + private record AppliedManifest( + Path targetFile, + @Nullable Path backupFile, + boolean hadOriginal) { + } + + /// Records an instance rename requested by the draft. + /// + /// @param from the source instance id + /// @param to the target instance id + private record RenameOperation(GameInstanceID from, GameInstanceID to) { + } + + /// Records an instance rename completed during commit. + /// + /// @param from the original instance id + /// @param to the renamed instance id + private record AppliedRename(GameInstanceID from, GameInstanceID to) { + } + + /// Records an instance root moved into staging during commit. + /// + /// @param originalRoot the published instance root + /// @param stagedRoot the temporary removal path + private record RemovedRoot(Path originalRoot, Path stagedRoot) { + } } 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 fb0e762ec5f..f837a0433a2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -30,9 +30,8 @@ /// Default implementation of a repository index snapshot for [DefaultGameRepository]. /// /// A snapshot begins unsealed so package-private writers can populate it. [#seal()] freezes the -/// instance map; afterwards any mutating method throws. Repository write paths must [#clone()] a -/// published snapshot, edit the copy, and publish it with -/// [DefaultGameRepository#publishSnapshot(DefaultGameRepositorySnapshot)]. +/// instance map; afterwards any mutating method throws. Repository drafts clone published snapshots, +/// edit the copies, and publish them through the repository's draft commit path. /// /// Once sealed, this object is exposed as a [GameRepositorySnapshot]. /// diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java index c289480aa5b..4eb06527b32 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameInstanceID.java @@ -28,34 +28,56 @@ import java.io.IOException; -/// @author Glavo +/// Identifies one game instance and forms the instance's directory name in repository layouts. +/// +/// An id must be a non-blank path segment. Directory separators and the special `.` and `..` +/// segments are rejected so layout operations cannot escape or alias the instances directory. +/// +/// @param id the validated instance id @NotNullByDefault @JsonAdapter(GameInstanceID.Adapter.class) @JsonSerializable public record GameInstanceID(String id) implements Comparable { + /// Returns whether `id` is a non-blank instance path segment. + /// + /// @param id the candidate id + /// @return whether the id satisfies the repository-independent safety requirements public static boolean isValid(String id) { - return !id.isBlank() && !id.contains("/") && !id.contains("\\"); + return !id.isBlank() + && !id.equals(".") + && !id.equals("..") + && !id.contains("/") + && !id.contains("\\"); } + /// Creates a validated instance id. + /// + /// @throws IllegalArgumentException if `id` is not valid public GameInstanceID { if (!isValid(id)) { throw new IllegalArgumentException("Invalid game instance id: " + id); } } + /// {@inheritDoc} @Override public int compareTo(GameInstanceID that) { return this.id.compareTo(that.id); } + /// Returns the instance id string. + /// + /// @return the value supplied to the constructor @Override public String toString() { return id; } + /// Serializes nullable instance ids as JSON strings. static final class Adapter extends TypeAdapter<@Nullable GameInstanceID> { + /// {@inheritDoc} @Override public @Nullable GameInstanceID read(JsonReader in) throws IOException { if (in.peek() == JsonToken.NULL) { @@ -66,6 +88,7 @@ static final class Adapter extends TypeAdapter<@Nullable GameInstanceID> { return new GameInstanceID(in.nextString()); } + /// {@inheritDoc} @Override public void write(JsonWriter out, @Nullable GameInstanceID value) throws IOException { out.value(value != null ? value.id() : null); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java index e1ae9f0841d..ad9cce612b6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -59,7 +59,11 @@ default Path getBaseDirectory() { /// Opens a draft for staging instance creates and manifest updates before a single publish. /// + /// A repository permits at most one open draft. Repository refreshes, layout changes, and other + /// writes are rejected until the draft is committed, aborted, or closed. + /// /// @return a new open draft cloned from [#getSnapshot()] + /// @throws IllegalStateException if this repository is already being modified /// @see GameRepositoryDraft GameRepositoryDraft openDraft(); @@ -103,6 +107,8 @@ default GameInstance getInstance(GameInstanceID id) throws NoSuchGameInstanceExc } /// Reloads repository state from the backing storage. + /// + /// @throws IllegalStateException if this repository is already being modified void refresh(); /// Creates a task that reloads repository state from the backing storage. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java index 7ffd4abf965..56a681de8c9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -21,16 +21,16 @@ import java.io.IOException; -/// A write session that stages stored instance manifests against an immutable base snapshot. +/// Provides the exclusive write session for a game repository. /// -/// The draft holds the repository's published [GameRepositorySnapshot] at open time. That snapshot -/// is never modified. [#put(GameInstanceManifest)] only records a staged stored manifest and writes -/// its JSON. [#commit()] builds a new snapshot from the base plus staged manifests and publishes it -/// once; only then does the repository index contain the corresponding [GameInstance] values. -/// [#abort()] discards staged changes, restores JSON for edited base instances, and removes -/// directories created only in this draft. +/// A draft owns an unpublished working snapshot initialized from [#getBaseSnapshot()]. Changes made +/// by [#put(GameInstanceManifest)] are immediately visible through [#getSnapshot()] but remain +/// invisible through [GameRepository#getSnapshot()] until [#commit()] succeeds. Manifest JSON is +/// written to draft-private temporary storage and moved into the repository during commit. /// -/// Drafts do not roll back global library or asset downloads outside instance roots. +/// A repository permits at most one open draft. Repository refreshes, layout changes, and other +/// writes are rejected while the draft is open. Aborting a draft removes instance directories that +/// were first created by that draft. Shared library, asset, and download caches are not reverted. /// /// @see GameRepository#openDraft() @NotNullByDefault @@ -41,16 +41,23 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @return the repository GameRepository getRepository(); - /// Returns the immutable base snapshot captured when this draft was opened, or the snapshot - /// published by a successful [#commit()]. + /// Returns the immutable published snapshot captured when this draft was opened. /// - /// While the draft is open, staged [#put] results are not part of this snapshot. After commit, - /// this method returns the newly published index. + /// @return the base snapshot + GameRepositorySnapshot getBaseSnapshot(); + + /// Returns the current unpublished working snapshot, or the snapshot published by a successful + /// [#commit()]. /// - /// @return the base snapshot, or the committed published snapshot - /// @throws IllegalStateException if the draft was aborted or closed without commit + /// @return the working or committed snapshot + /// @throws IllegalStateException if the draft was aborted or failed GameRepositorySnapshot getSnapshot(); + /// Returns this draft's lifecycle state. + /// + /// @return the current state + GameRepositoryDraftState getState(); + /// Returns whether this draft still accepts mutations. /// /// @return whether the draft is open @@ -61,47 +68,72 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @return whether [#commit()] has completed successfully boolean isCommitted(); - /// Returns whether a stored manifest for `instanceId` is staged or already present in the base - /// snapshot. - /// - /// This does not imply a [GameInstance] is available from the published repository until - /// [#commit()]. + /// Returns whether the working snapshot contains `instanceId`. /// /// @param instanceId the instance id - /// @return whether the id is staged or present in the base snapshot + /// @return whether the id is present in the working snapshot + /// @throws IllegalStateException if the draft is not open boolean hasInstance(GameInstanceID instanceId); - /// Stages a stored instance manifest without creating a repository [GameInstance]. + /// Adds or replaces a stored manifest in the unpublished working snapshot. /// - /// Writes the manifest JSON under the repository layout and records the change for - /// [#commit()]. No instance index entry exists for a newly staged id until commit. Callers that - /// need a [GameInstance] must [#commit()] and then use [GameRepository#getInstance(GameInstanceID)]. + /// The manifest JSON is written to draft-private temporary storage. The returned [GameInstance] + /// belongs to the working snapshot and may be used by installation code before commit. It may + /// become stale after another call to this method for the same id. /// /// @param manifest the persistent instance manifest - /// @throws IOException if the manifest cannot be written - /// @throws IllegalStateException if the draft is closed - void put(GameInstanceManifest manifest) throws IOException; + /// @return the instance in the updated working snapshot + /// @throws IOException if the temporary manifest cannot be written + /// @throws IllegalStateException if the draft is not open + GameInstance put(GameInstanceManifest manifest) throws IOException; + + /// Removes an instance from the unpublished working snapshot. + /// + /// Its instance root is retained until commit and moved out of the repository before the new + /// snapshot is published. Aborting leaves the published instance and its files unchanged. + /// + /// @param instanceId the instance to remove + /// @throws NoSuchGameInstanceException if the working snapshot does not contain the instance + /// @throws IllegalStateException if the draft is not open + void remove(GameInstanceID instanceId); + + /// Renames an instance in the unpublished working snapshot. + /// + /// Direct inheritance references managed by the repository are updated in the same draft. The + /// source directory remains at its published location until commit. + /// + /// @param from the current instance id + /// @param to the target instance id + /// @throws IOException if the target directory already exists or a temporary + /// manifest cannot be written + /// @throws NoSuchGameInstanceException if the working snapshot does not contain `from` + /// @throws IllegalArgumentException if the working snapshot already contains `to` + /// @throws IllegalStateException if the draft is not open + void rename(GameInstanceID from, GameInstanceID to) throws IOException; - /// Builds a new snapshot from the base plus staged manifests and publishes it. + /// Applies staged manifest files and publishes the working snapshot. /// /// After this method returns, [GameRepository#getInstance(GameInstanceID)] will resolve staged /// ids from the published index. /// - /// @throws IllegalStateException if the draft is closed or already committed - void commit(); + /// @return the newly published snapshot + /// @throws IOException if staged filesystem changes cannot be applied + /// @throws IllegalStateException if the draft is not open or is not the repository's active draft + GameRepositorySnapshot commit() throws IOException; /// Discards staged changes without publishing a new snapshot. /// - /// Restores JSON for instances that existed in the base and were modified, and removes instance - /// directories that were created only in this draft. Global caches (libraries, assets) are not - /// reverted. Idempotent when already aborted. + /// Removes temporary manifests and instance directories created only by this draft. Global + /// caches (libraries, assets) are not reverted. This method is idempotent after a successful + /// abort. /// + /// @throws IOException if cleanup fails /// @throws IllegalStateException if the draft was already committed - void abort(); + void abort() throws IOException; - /// Aborts this draft when it was not committed. + /// Aborts this draft when it is still open. /// /// @see #abort() @Override - void close(); + void close() throws IOException; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java new file mode 100644 index 00000000000..e87ae824251 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java @@ -0,0 +1,39 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jetbrains.annotations.NotNullByDefault; + +/// Describes the lifecycle state of a [GameRepositoryDraft]. +@NotNullByDefault +public enum GameRepositoryDraftState { + /// The draft accepts changes and may be committed or aborted. + OPEN, + + /// The draft is applying staged files and publishing its working snapshot. + COMMITTING, + + /// The draft completed its commit and no longer accepts changes. + COMMITTED, + + /// The draft discarded its changes and no longer accepts changes. + ABORTED, + + /// The draft could not complete a commit or cleanup operation. + FAILED +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java new file mode 100644 index 00000000000..7401c750b21 --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java @@ -0,0 +1,365 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.game; + +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/// Tests exclusive repository drafts and their filesystem visibility boundary. +@NotNullByDefault +public final class DefaultGameRepositoryDraftTest { + + /// Rejects path-segment instance ids before they can reach repository deletion code. + @Test + public void testRejectsSpecialPathSegmentIds() { + assertThrows(IllegalArgumentException.class, () -> new GameInstanceID(".")); + assertThrows(IllegalArgumentException.class, () -> new GameInstanceID("..")); + } + + /// Keeps a new manifest private until commit and publishes the working instance once committed. + @Test + public void testCommitPublishesStagedManifest(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest manifest = new GameInstanceManifest(id).withMainClass("example.Main"); + Path manifestFile = repository.getLayout().getInstanceJson(id); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + GameInstance workingInstance = draft.put(manifest); + + assertEquals(manifest, workingInstance.getManifest()); + assertTrue(draft.getSnapshot().hasInstance(id)); + assertFalse(repository.hasInstance(id)); + assertFalse(Files.exists(manifestFile)); + + GameRepositorySnapshot committed = draft.commit(); + assertEquals(GameRepositoryDraftState.COMMITTED, draft.getState()); + assertEquals(manifest, committed.getInstance(id).getManifest()); + } + + assertTrue(repository.hasInstance(id)); + GameInstanceManifest stored = JsonUtils.fromNonNullJson( + Files.readString(manifestFile), + GameInstanceManifest.class); + assertEquals(id, stored.id()); + assertEquals("example.Main", stored.mainClass()); + } + + /// Aborting removes files below a root that was first created by the draft. + @Test + public void testAbortRemovesDraftCreatedInstanceRoot(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + Path root = repository.getLayout().getInstanceRoot(id); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + draft.put(new GameInstanceManifest(id)); + Files.createDirectories(root); + Files.writeString(root.resolve("downloaded.jar"), "content"); + } + + assertFalse(Files.exists(root)); + assertFalse(repository.hasInstance(id)); + + try (DefaultGameRepositoryDraft ignored = repository.openDraft()) { + assertTrue(ignored.isOpen()); + } + } + + /// Leaves the published manifest and its JSON unchanged when an update is aborted. + @Test + public void testAbortDoesNotOverwriteExistingManifest(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main"); + GameInstanceManifest updated = original.withMainClass("updated.Main"); + repository.save(original); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + draft.put(updated); + assertEquals(updated, draft.getSnapshot().getInstance(id).getManifest()); + assertEquals(original, repository.getInstance(id).getManifest()); + } + + Path manifestFile = repository.getLayout().getInstanceJson(id); + assertEquals(original, repository.getInstance(id).getManifest()); + GameInstanceManifest stored = JsonUtils.fromNonNullJson( + Files.readString(manifestFile), + GameInstanceManifest.class); + assertEquals(id, stored.id()); + assertEquals("original.Main", stored.mainClass()); + } + + /// Rejects overlapping drafts and direct snapshot writes while a draft owns the repository. + @Test + public void testDraftExcludesOtherRepositoryWrites(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertThrows(IllegalStateException.class, repository::openDraft); + assertThrows(IllegalStateException.class, repository::refresh); + assertThrows( + IllegalStateException.class, + () -> repository.setBaseDirectory(tempDirectory.resolve("other"))); + assertTrue(draft.isOpen()); + } + + repository.refresh(); + } + + /// Refuses to claim and later delete an unregistered directory that predates the draft. + @Test + public void testPutRejectsPreexistingUnregisteredDirectory(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + Path root = repository.getLayout().getInstanceRoot(id); + Files.createDirectories(root); + Path retained = root.resolve("retained.txt"); + Files.writeString(retained, "content"); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + assertThrows(FileAlreadyExistsException.class, () -> draft.put(new GameInstanceManifest(id))); + } + + assertTrue(Files.exists(retained)); + } + + /// Keeps a staged removal private and preserves the published files when the draft aborts. + @Test + public void testAbortPreservesRemovedInstance(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + repository.save(new GameInstanceManifest(id)); + Path root = repository.getLayout().getInstanceRoot(id); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + draft.remove(id); + assertFalse(draft.getSnapshot().hasInstance(id)); + assertTrue(repository.hasInstance(id)); + assertTrue(Files.isDirectory(root)); + } + + assertTrue(repository.hasInstance(id)); + assertTrue(Files.isDirectory(root)); + } + + /// Renames an instance and its direct inheritance references in one draft commit. + @Test + public void testRenameCommitsFilesAndReferences(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID parentId = new GameInstanceID("parent"); + GameInstanceID renamedId = new GameInstanceID("renamed"); + GameInstanceID childId = new GameInstanceID("child"); + repository.save(new GameInstanceManifest(parentId)); + repository.save(new GameInstanceManifest(childId).withInheritsFrom(parentId)); + + assertTrue(repository.renameInstance(parentId, renamedId)); + + assertFalse(repository.hasInstance(parentId)); + assertTrue(repository.hasInstance(renamedId)); + assertEquals(renamedId, repository.getInstance(childId).getManifest().inheritsFrom()); + assertFalse(Files.exists(repository.getLayout().getInstanceRoot(parentId))); + assertTrue(Files.isRegularFile(repository.getLayout().getInstanceJson(renamedId))); + } + + /// Removes a registered instance through a one-shot draft. + @Test + public void testRemoveInstanceUsesDraftCommit(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + repository.save(new GameInstanceManifest(id)); + Path root = repository.getLayout().getInstanceRoot(id); + + assertTrue(repository.removeInstanceFromDisk(id)); + + assertFalse(repository.hasInstance(id)); + assertFalse(Files.exists(root)); + } + + /// Restores staged manifests and releases exclusivity when publication fails before replacement. + @Test + public void testCommitFailureRollsBackAndReleasesDraft(@TempDir Path tempDirectory) throws IOException { + FailingRepository repository = new FailingRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main"); + repository.save(original); + repository.failDraftPublish = true; + + DefaultGameRepositoryDraft draft = repository.openDraft(); + draft.put(original.withMainClass("updated.Main")); + assertThrows(IllegalStateException.class, draft::commit); + + assertEquals(GameRepositoryDraftState.FAILED, draft.getState()); + assertEquals(original, repository.getInstance(id).getManifest()); + GameInstanceManifest stored = JsonUtils.fromNonNullJson( + Files.readString(repository.getLayout().getInstanceJson(id)), + GameInstanceManifest.class); + assertEquals("original.Main", stored.mainClass()); + try (DefaultGameRepositoryDraft ignored = repository.openDraft()) { + assertTrue(ignored.isOpen()); + } + } + + /// Runs an asynchronous instance update against the unpublished working instance. + @Test + public void testUpdateInstanceAsyncCommitsWorkingManifest(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + repository.save(new GameInstanceManifest(id).withMainClass("original.Main")); + + Task update = repository.updateInstanceAsync(id, workingInstance -> Task.supplyAsync(() -> + workingInstance.getManifest().withMainClass("updated.Main"))); + assertTrue(update.executor().test()); + + assertEquals("updated.Main", repository.getInstance(id).getManifest().mainClass()); + } + + /// Rejects an asynchronous update that attempts to create a different instance id. + @Test + public void testUpdateInstanceAsyncRejectsChangedId(@TempDir Path tempDirectory) throws Exception { + TestRepository repository = new TestRepository(tempDirectory); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceID otherId = new GameInstanceID("other"); + repository.save(new GameInstanceManifest(id).withMainClass("original.Main")); + + Task update = repository.updateInstanceAsync(id, workingInstance -> + Task.supplyAsync(() -> workingInstance.getManifest().withId(otherId))); + assertFalse(update.executor().test()); + + assertEquals("original.Main", repository.getInstance(id).getManifest().mainClass()); + assertFalse(repository.hasInstance(otherId)); + try (DefaultGameRepositoryDraft ignored = repository.openDraft()) { + assertTrue(ignored.isOpen()); + } + } + + /// Minimal repository implementation for draft tests. + @NotNullByDefault + private static class TestRepository extends DefaultGameRepository { + + /// Creates a test repository rooted at `baseDirectory`. + /// + /// @param baseDirectory the repository base directory + private TestRepository(Path baseDirectory) { + super(baseDirectory); + } + + /// {@inheritDoc} + @Override + protected DefaultGameRepositoryLayout createLayout(Path baseDirectory) { + return new DefaultGameRepositoryLayout(baseDirectory); + } + + /// {@inheritDoc} + @Override + protected TestGameInstance createInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { + return new TestGameInstance(snapshot, id, manifest, manifestFile); + } + } + + /// Repository that can fail draft publication before changing the published snapshot. + @NotNullByDefault + private static final class FailingRepository extends TestRepository { + + /// Whether the next draft publication should fail. + private boolean failDraftPublish; + + /// Creates a failing test repository rooted at `baseDirectory`. + /// + /// @param baseDirectory the repository base directory + private FailingRepository(Path baseDirectory) { + super(baseDirectory); + } + + /// {@inheritDoc} + @Override + void publishDraftSnapshot( + DefaultGameRepositoryDraft draft, + DefaultGameRepositorySnapshot newSnapshot) { + if (failDraftPublish) { + failDraftPublish = false; + throw new IllegalStateException("Simulated publication failure"); + } + super.publishDraftSnapshot(draft, newSnapshot); + } + } + + /// Minimal snapshot-bound instance implementation for draft tests. + @NotNullByDefault + private static final class TestGameInstance extends DefaultGameInstance { + + /// Creates a test instance. + /// + /// @param snapshot the owning snapshot + /// @param id the instance id + /// @param manifest the stored manifest + /// @param manifestFile the non-conventional manifest path, or `null` + private TestGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + @Nullable Path manifestFile) { + super(snapshot, id, manifest, manifestFile); + } + + /// Creates a test instance that may reuse compatible state. + /// + /// @param snapshot the owning snapshot + /// @param id the instance id + /// @param manifest the stored manifest + /// @param shareSession the prior snapshot member + private TestGameInstance( + DefaultGameRepositorySnapshot snapshot, + GameInstanceID id, + GameInstanceManifest manifest, + TestGameInstance shareSession) { + super(snapshot, id, manifest, shareSession); + } + + /// {@inheritDoc} + @Override + protected TestGameInstance withNewSnapshot(DefaultGameRepositorySnapshot newSnapshot) { + return new TestGameInstance(newSnapshot, id, manifest, this); + } + + /// {@inheritDoc} + @Override + protected TestGameInstance withManifest( + DefaultGameRepositorySnapshot newSnapshot, + GameInstanceManifest manifest) { + return new TestGameInstance(newSnapshot, id, manifest, this); + } + } +} From 6a221038f33488af79e550a8ac8892a5d71b8271 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 11 Aug 2026 21:35:00 +0800 Subject: [PATCH 164/199] refactor(DefaultGameRepository, DefaultGameRepositoryDraft, HMCLGameRepository): simplify draft handling and improve thread safety --- .../hmcl/game/HMCLGameRepository.java | 66 ++++++------- .../hmcl/game/DefaultGameRepository.java | 96 +++++-------------- .../hmcl/game/DefaultGameRepositoryDraft.java | 24 ++--- .../hmcl/game/GameRepositoryDraft.java | 1 + 4 files changed, 66 insertions(+), 121 deletions(-) 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 38adc5b5ace..58a33e0af51 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -47,6 +47,7 @@ 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; @@ -64,11 +65,8 @@ public final class HMCLGameRepository extends DefaultGameRepository { /// The selected instance resolved from the current repository snapshot. private final ReadOnlyObjectWrapper<@Nullable HMCLGameInstance> selectedInstance; - /// Monitor guarding settings prepared before a new instance draft is opened. - private final Object preparedInstanceMonitor = new Object(); - /// Settings reservations transferred to the next draft that creates the corresponding id. - private final Map preparedInstanceSettings = new HashMap<>(); + private final Map preparedInstanceSettings = new ConcurrentHashMap<>(); /// Creates a repository backed by the given game directory. /// @@ -101,9 +99,7 @@ protected HMCLGameRepositorySnapshot createSnapshot(DefaultGameRepositoryLayout @Override public void setBaseDirectory(Path baseDirectory) { super.setBaseDirectory(baseDirectory); - synchronized (preparedInstanceMonitor) { - preparedInstanceSettings.clear(); - } + preparedInstanceSettings.clear(); } /// {@inheritDoc} @@ -111,11 +107,9 @@ public void setBaseDirectory(Path baseDirectory) { /// Accepts an existing root only when this repository reserved the id while the root was absent. @Override protected boolean mayClaimDraftInstanceRoot(GameInstanceID instanceId, Path instanceRoot) { - synchronized (preparedInstanceMonitor) { - PreparedInstanceSettings prepared = preparedInstanceSettings.get(instanceId); - if (prepared != null) { - return prepared.instanceRoot().equals(instanceRoot) && prepared.rootWasAbsent(); - } + PreparedInstanceSettings prepared = preparedInstanceSettings.get(instanceId); + if (prepared != null) { + return prepared.instanceRoot().equals(instanceRoot) && prepared.rootWasAbsent(); } return super.mayClaimDraftInstanceRoot(instanceId, instanceRoot); } @@ -126,18 +120,16 @@ protected boolean mayClaimDraftInstanceRoot(GameInstanceID instanceId, Path inst /// draft owns the instance root. @Override protected void initializeDraftInstanceRoot(GameInstanceID instanceId, Path instanceRoot) throws IOException { - synchronized (preparedInstanceMonitor) { - 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); + 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 @@ -351,22 +343,20 @@ public void ensureIsolatedRunningDirectory(GameInstanceID instanceId) { } Path instanceRoot = getLayout().getInstanceRoot(instanceId).toAbsolutePath().normalize(); - synchronized (preparedInstanceMonitor) { - 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))); + 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() { 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 0aa0704517b..d169a968e3d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -95,14 +95,8 @@ private static boolean hasClassicInstance(Path baseDirectory) { /// Published snapshot, always updated on the JavaFX application thread when the toolkit is live. private final ObjectProperty snapshot; - /// Monitor guarding the exclusive draft and direct repository write state. - private final Object writeSessionMonitor = new Object(); - - /// The repository's sole open draft, or `null` when no draft is active. - private @Nullable DefaultGameRepositoryDraft activeDraft; - - /// Number of refresh, layout-replacement, or orphan-cleanup writes currently in progress. - private int activeDirectWrites; + /// Atomically holds the repository's sole open draft, or `null` when no draft is active. + private final AtomicReference<@Nullable DefaultGameRepositoryDraft> activeDraft = new AtomicReference<>(); /// Whether at least one full refresh has completed since the base directory was set. private volatile boolean loaded; @@ -151,15 +145,11 @@ protected void initializeDraftInstanceRoot(GameInstanceID instanceId, Path insta /// @param baseDirectory the new repository base directory /// @throws IllegalStateException if a draft is active public void setBaseDirectory(Path baseDirectory) { - beginDirectWrite("set base directory"); - try { - // Mark unloaded before publishing so snapshot listeners do not treat the empty snapshot as ready. - this.loaded = false; - DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); - publishSnapshot(initial); - } finally { - endDirectWrite("set base directory"); - } + checkNoActiveDraft("set base directory"); + // Mark unloaded before publishing so snapshot listeners do not treat the empty snapshot as ready. + this.loaded = false; + DefaultGameRepositorySnapshot initial = createSnapshot(createLayout(baseDirectory)); + publishSnapshot(initial); } /// {@inheritDoc} @@ -217,10 +207,8 @@ void publishDraftSnapshot( /// @param draft the draft to verify /// @throws IllegalStateException if `draft` is not active void checkActiveDraft(DefaultGameRepositoryDraft draft) { - synchronized (writeSessionMonitor) { - if (activeDraft != draft) { - throw new IllegalStateException("Draft is not the active repository draft"); - } + if (activeDraft.get() != draft) { + throw new IllegalStateException("Draft is not the active repository draft"); } } @@ -229,11 +217,8 @@ void checkActiveDraft(DefaultGameRepositoryDraft draft) { /// @param draft the draft that completed, aborted, or failed /// @throws IllegalStateException if `draft` is not active void releaseDraft(DefaultGameRepositoryDraft draft) { - synchronized (writeSessionMonitor) { - if (activeDraft != draft) { - throw new IllegalStateException("Draft is not the active repository draft"); - } - activeDraft = null; + if (!activeDraft.compareAndSet(draft, null)) { + throw new IllegalStateException("Draft is not the active repository draft"); } } @@ -292,12 +277,8 @@ public boolean isLoaded() { /// @throws IllegalStateException if a draft is active @Override public void refresh() { - beginDirectWrite("refresh"); - try { - refreshRepository(); - } finally { - endDirectWrite("refresh"); - } + checkNoActiveDraft("refresh"); + refreshRepository(); } /// Reloads and publishes repository state while the caller owns the direct-write session. @@ -505,7 +486,7 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { } } - beginDirectWrite("remove instance"); + checkNoActiveDraft("remove instance"); try { Path file = getLayout().getInstanceRoot(id); if (Files.notExists(file)) { @@ -539,11 +520,7 @@ public boolean removeInstanceFromDisk(GameInstanceID id) { } return true; } finally { - try { - refreshRepository(); - } finally { - endDirectWrite("remove instance"); - } + refreshRepository(); } } @@ -593,18 +570,11 @@ public Path getInstanceJson(GameInstanceID instanceId) { /// @return a new open draft @Override public DefaultGameRepositoryDraft openDraft() { - synchronized (writeSessionMonitor) { - if (activeDraft != null) { - throw new IllegalStateException("Another repository draft is already open"); - } - if (activeDirectWrites != 0) { - throw new IllegalStateException("Repository is currently performing a direct write"); - } - - DefaultGameRepositoryDraft draft = new DefaultGameRepositoryDraft(this); - activeDraft = draft; - return draft; + DefaultGameRepositoryDraft draft = new DefaultGameRepositoryDraft(this); + if (!activeDraft.compareAndSet(null, draft)) { + throw new IllegalStateException("Another repository draft is already open"); } + return draft; } /// Writes a stored manifest and publishes a new snapshot in a single draft commit. @@ -644,7 +614,7 @@ public Task saveAsync(GameInstanceManifest instanceManifes public Task updateInstanceAsync( GameInstanceID instanceId, ExceptionalFunction, E> updater) { - AtomicReference active = new AtomicReference<>(); + var active = new AtomicReference<@Nullable GameRepositoryDraft>(); return Task.supplyAsync(() -> { GameRepositoryDraft draft = openDraft(); active.set(draft); @@ -711,29 +681,13 @@ protected abstract DefaultGameInstance createInstance( GameInstanceManifest manifest, @Nullable Path manifestFile); - /// Begins a direct repository write that must not overlap a draft. + /// Verifies that no draft is currently active. /// - /// @param operation human-readable operation name used in diagnostics + /// @param operation operation rejected when a draft is active /// @throws IllegalStateException if a draft is active - private void beginDirectWrite(String operation) { - synchronized (writeSessionMonitor) { - if (activeDraft != null) { - throw new IllegalStateException("Repository has an open draft; cannot " + operation); - } - activeDirectWrites++; - } - } - - /// Ends a direct repository write. - /// - /// @param operation the operation name passed to [#beginDirectWrite(String)] - /// @throws IllegalStateException if no direct write is active - private void endDirectWrite(String operation) { - synchronized (writeSessionMonitor) { - if (activeDirectWrites == 0) { - throw new IllegalStateException("Direct repository write is not active: " + operation); - } - activeDirectWrites--; + private void checkNoActiveDraft(String operation) { + if (activeDraft.get() != null) { + throw new IllegalStateException("Repository has an open draft; cannot " + operation); } } 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 40cefd8eefe..7bc63c3d1e5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -43,7 +43,7 @@ /// draft-private directory. Instance installers may use the returned working [GameInstance] and /// write instance-owned files before commit. A successful commit moves all staged manifests into /// place and publishes the working snapshot once. Shared library and asset cache writes are outside -/// the rollback boundary. +/// the rollback boundary. Instances of this class are not thread-safe. @NotNullByDefault public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { @@ -97,7 +97,7 @@ public GameRepositorySnapshot getBaseSnapshot() { /// {@inheritDoc} @Override - public synchronized GameRepositorySnapshot getSnapshot() { + public GameRepositorySnapshot getSnapshot() { if (state == GameRepositoryDraftState.ABORTED || state == GameRepositoryDraftState.FAILED) { throw new IllegalStateException("Draft is " + state.name().toLowerCase()); } @@ -106,39 +106,39 @@ public synchronized GameRepositorySnapshot getSnapshot() { /// {@inheritDoc} @Override - public synchronized GameRepositoryDraftState getState() { + public GameRepositoryDraftState getState() { return state; } /// {@inheritDoc} @Override - public synchronized boolean isOpen() { + public boolean isOpen() { return state == GameRepositoryDraftState.OPEN; } /// {@inheritDoc} @Override - public synchronized boolean isCommitted() { + public boolean isCommitted() { return state == GameRepositoryDraftState.COMMITTED; } /// {@inheritDoc} @Override - public synchronized boolean hasInstance(GameInstanceID instanceId) { + public boolean hasInstance(GameInstanceID instanceId) { checkOpen(); return workingSnapshot.hasInstance(instanceId); } /// {@inheritDoc} @Override - public synchronized DefaultGameInstance put(GameInstanceManifest manifest) throws IOException { + public DefaultGameInstance put(GameInstanceManifest manifest) throws IOException { checkOpen(); return stageManifest(manifest, true); } /// {@inheritDoc} @Override - public synchronized void remove(GameInstanceID instanceId) { + public void remove(GameInstanceID instanceId) { checkOpen(); if (workingSnapshot.get(instanceId) == null) { throw new NoSuchGameInstanceException(instanceId); @@ -151,7 +151,7 @@ public synchronized void remove(GameInstanceID instanceId) { /// {@inheritDoc} @Override - public synchronized void rename(GameInstanceID from, GameInstanceID to) throws IOException { + public void rename(GameInstanceID from, GameInstanceID to) throws IOException { checkOpen(); DefaultGameInstance source = workingSnapshot.get(from); if (source == null) { @@ -231,7 +231,7 @@ private DefaultGameInstance stageManifest( /// {@inheritDoc} @Override - public synchronized DefaultGameRepositorySnapshot commit() throws IOException { + public DefaultGameRepositorySnapshot commit() throws IOException { checkOpen(); repository.checkActiveDraft(this); state = GameRepositoryDraftState.COMMITTING; @@ -274,7 +274,7 @@ public synchronized DefaultGameRepositorySnapshot commit() throws IOException { /// {@inheritDoc} @Override - public synchronized void abort() throws IOException { + public void abort() throws IOException { if (state == GameRepositoryDraftState.ABORTED) { return; } @@ -298,7 +298,7 @@ public synchronized void abort() throws IOException { /// {@inheritDoc} @Override - public synchronized void close() throws IOException { + public void close() throws IOException { if (state == GameRepositoryDraftState.OPEN) { abort(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java index 56a681de8c9..fed115a8437 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -31,6 +31,7 @@ /// A repository permits at most one open draft. Repository refreshes, layout changes, and other /// writes are rejected while the draft is open. Aborting a draft removes instance directories that /// were first created by that draft. Shared library, asset, and download caches are not reverted. +/// Drafts are not thread-safe; callers must serialize all operations on a draft. /// /// @see GameRepository#openDraft() @NotNullByDefault From 7f4ae5fa60e45beda0e4bb05668b5e88d8a87376 Mon Sep 17 00:00:00 2001 From: Glavo Date: Tue, 11 Aug 2026 22:00:11 +0800 Subject: [PATCH 165/199] Separate repository drafts from snapshot instances Assisted-by: codex:gpt-5.6-sol --- .../hmcl/game/HMCLGameRepository.java | 12 ++ .../hmcl/game/HMCLGameRepositorySnapshot.java | 5 - .../hmcl/game/HMCLModpackInstallTask.java | 4 +- .../UpdateInstallerWizardProvider.java | 8 +- .../hmcl/ui/instances/InstallerListPage.java | 10 +- .../hmcl/setting/GameDirectoriesTest.java | 15 ++- .../download/DefaultDependencyManager.java | 100 ++++++++++++--- .../hmcl/download/DefaultGameBuilder.java | 29 +++-- .../hmcl/game/DefaultGameRepository.java | 22 +++- .../hmcl/game/DefaultGameRepositoryDraft.java | 117 ++++++++---------- .../game/DefaultGameRepositorySnapshot.java | 41 ++---- .../jackhuang/hmcl/game/GameRepository.java | 2 +- .../hmcl/game/GameRepositoryDraft.java | 49 +++----- .../hmcl/game/GameRepositoryDraftState.java | 2 +- .../game/DefaultGameRepositoryDraftTest.java | 10 +- 15 files changed, 236 insertions(+), 190 deletions(-) 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 58a33e0af51..0b04325bb84 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -273,6 +273,18 @@ 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) { + return computeRunDirectory( + instanceId, + Files.exists(getLayout().getModpackConfigurationFile(instanceId)), + getInstanceGameSettings(instanceId)); + } + private String selectedRunningDirectory( GameSettings.@Nullable Instance localSetting, boolean useInstanceRunningDirectory) { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java index 1278547ade9..94ac7d9c083 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepositorySnapshot.java @@ -47,11 +47,6 @@ protected HMCLGameRepositorySnapshot newEmpty() { return new HMCLGameRepositorySnapshot(getRepository(), getLayout()); } - @Override - public HMCLGameRepositorySnapshot clone() { - return (HMCLGameRepositorySnapshot) super.clone(); - } - @SuppressWarnings("unchecked") @Override public Collection getInstances() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java index cf428997052..cdfc80d1679 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -92,7 +92,7 @@ public void execute() throws Exception { GameInstanceManifest originalManifest = JsonUtils.GSON.fromJson(json, GameInstanceManifest.class).withId(instanceId).withJar(null); GameComponentAnalyzer analyzer = GameComponentAnalyzer.analyze(originalManifest, null); - dependencies.add(repository.updateInstanceAsync(instanceId, draftInstance -> { + dependencies.add(repository.updateInstanceAsync(instanceId, publishedInstance -> { Task libraryTask = Task.supplyAsync(() -> originalManifest); // Forge and OptiFine libraries must be regenerated by their installers. for (GameComponentAnalyzer.Mark mark : analyzer) { @@ -104,7 +104,7 @@ public void execute() throws Exception { continue; } libraryTask = libraryTask.thenComposeAsync(manifest -> dependency.installComponentAsync( - draftInstance, + publishedInstance, manifest, modpack.getGameVersion(), mark.componentType().getPatchId(), 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 d86a97b1a46..875fb25f6ca 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 @@ -82,16 +82,16 @@ public Object finish(SettingsMap settings) { } } - return gameInstance.getRepository().updateInstanceAsync(gameInstance.getId(), draftInstance -> { - Task update = Task.supplyAsync(draftInstance::getManifest); + return gameInstance.getRepository().updateInstanceAsync(gameInstance.getId(), publishedInstance -> { + Task update = Task.supplyAsync(publishedInstance::getManifest); for (Object value : settings.asStringMap().values()) { if (value instanceof RemoteVersion remoteVersion) { update = update.thenComposeAsync(manifest -> - dependencyManager.installComponentAsync(draftInstance, manifest, remoteVersion)); + dependencyManager.installComponentAsync(publishedInstance, manifest, remoteVersion)); } else if (value instanceof RemoveVersionAction removeVersionAction) { update = update.thenComposeAsync(manifest -> dependencyManager.removeComponentAsync( - draftInstance, + publishedInstance, manifest, removeVersionAction.componentType)); } 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 be353617daa..34f9ea44fe7 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 @@ -107,8 +107,8 @@ public void loadInstance(HMCLGameInstance.Optional instance) { component.setOnRemove(() -> repository.updateInstanceAsync( gameInstance.getId(), - draftInstance -> repository.getDependency().removeComponentAsync( - draftInstance, + publishedInstance -> repository.getDependency().removeComponentAsync( + publishedInstance, component.getComponentType())) .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) .start()); @@ -124,8 +124,8 @@ public void loadInstance(HMCLGameInstance.Optional instance) { installerItem.versionProperty().set(new InstallerItem.InstalledState(mark.version(), false, false)); installerItem.setOnRemove(() -> repository.updateInstanceAsync( gameInstance.getId(), - draftInstance -> repository.getDependency().removeComponentAsync( - draftInstance, + publishedInstance -> repository.getDependency().removeComponentAsync( + publishedInstance, mark.componentType())) .withRunAsync(Schedulers.javafx(), this::reloadCurrentInstance) .start()); @@ -155,7 +155,7 @@ private void doInstallOffline(Path file) { HMCLGameRepository repository = gameInstance.getRepository(); Task task = repository.updateInstanceAsync( gameInstance.getId(), - draftInstance -> repository.getDependency().installComponentAsync(draftInstance, file)); + publishedInstance -> repository.getDependency().installComponentAsync(publishedInstance, file)); task.setName(i18n("install.installer.install_offline")); TaskExecutor executor = task.executor(new TaskListener() { @Override 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 92e89fe39c6..28e862ebead 100644 --- a/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java +++ b/HMCL/src/test/java/org/jackhuang/hmcl/setting/GameDirectoriesTest.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; +import org.jackhuang.hmcl.game.GameRepositoryDraft; import org.jackhuang.hmcl.game.HMCLGameInstance; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.util.FileSaver; @@ -438,9 +439,9 @@ public void repositoryDirectoryFollowsGameDirectoryPath() throws ReflectiveOpera } } - /// Tests that isolation settings written before install make a registered instance use the version root. + /// Tests that an unpublished isolated installation resolves paths without a [HMCLGameInstance]. @Test - public void newIsolatedInstallingInstanceUsesVersionRootAfterPlaceholderSave(@TempDir Path tempDirectory) + public void newIsolatedInstallationUsesVersionRootBeforePublication(@TempDir Path tempDirectory) throws Exception { GameSettingsPresetID defaultPresetId = GameSettingsPresetID.parse("game-settings-preset:123e4567-e89b-12d3-a456-426614174002"); @@ -465,9 +466,15 @@ public void newIsolatedInstallingInstanceUsesVersionRootAfterPlaceholderSave(@Te assertFalse(repository.hasInstance(id)); - // Isolation is configured first; install then registers a placeholder instance. repository.applyDefaultIsolationSettingForNewInstance(id, true); - repository.saveAsync(new GameInstanceManifest(id)).run(); + 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); assertEquals(repository.getLayout().getInstanceRoot(id), instance.getRunDirectory()); 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 cb8d71cb6f6..40d4405d990 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -28,6 +28,8 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -35,11 +37,11 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; -import java.util.concurrent.atomic.AtomicReference; import java.util.regex.Matcher; import java.util.regex.Pattern; /// Provides downloads and game-component installation for one game repository. +@NotNullByDefault public class DefaultDependencyManager extends AbstractDependencyManager { /// The repository whose layout and registered instances are managed. @@ -138,7 +140,7 @@ public Task checkPatchCompletionAsync( continue; if (type == GameComponentType.OPTIFINE) { - String optifinePatchVersion = Optional.ofNullable(instance.getComponentVersion(type)).map(optifineVersion -> { + @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; }) @@ -197,24 +199,94 @@ public Task installComponentAsync( throw new IllegalArgumentException("baseManifest id does not match instance"); } - AtomicReference removedComponentManifest = new AtomicReference<>(); Path modsDirectory = instance.getModsDirectory(); return removeComponentAsync(instance, baseManifest, libraryVersion.getComponentType()) - .thenComposeAsync(manifest -> { - removedComponentManifest.set(manifest); - return libraryVersion.getInstallTask(this, manifest, modsDirectory); - }) - .thenApplyAsync(patch -> { - if (patch == null) { - return removedComponentManifest.get(); - } else { - return removedComponentManifest.get().addPatch(patch); - } - }) + .thenComposeAsync(manifest -> libraryVersion + .getInstallTask(this, manifest, modsDirectory) + .thenApplyAsync(patch -> patch == null ? manifest : manifest.addPatch(patch))) .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getLibraryId(), libraryVersion.getSelfVersion())); } + /// Installs a component into an unpublished new instance without constructing a + /// [GameInstance]. + /// + /// @param instanceId the unpublished instance id + /// @param baseManifest the working manifest for this step + /// @param gameVersion the Minecraft version used for component analysis + /// @param libraryVersion the remote component to install + /// @return the task producing the updated manifest (not yet committed) + Task installNewInstanceComponentAsync( + GameInstanceID instanceId, + GameInstanceManifest baseManifest, + String gameVersion, + RemoteVersion libraryVersion) { + 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), + 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.getLibraryId(), + libraryVersion.getSelfVersion())); + } + + /// Resolves and installs a component into an unpublished new instance. + /// + /// @param instanceId the unpublished instance id + /// @param baseManifest the working manifest for this step + /// @param gameVersion the Minecraft version used to look up the remote list + /// @param libraryId the component list id, such as `game` or `forge` + /// @param libraryVersion the component version id + /// @return the installation task + Task installNewInstanceComponentAsync( + GameInstanceID instanceId, + GameInstanceManifest baseManifest, + String gameVersion, + String libraryId, + String libraryVersion) { + if (!instanceId.equals(baseManifest.id())) { + throw new IllegalArgumentException("baseManifest id does not match instanceId"); + } + + VersionList versionList = getVersionList(libraryId); + return versionList.loadAsync(gameVersion) + .thenComposeAsync(() -> installNewInstanceComponentAsync( + instanceId, + baseManifest, + gameVersion, + versionList.getVersion(gameVersion, libraryVersion) + .orElseThrow(() -> new IOException( + "Remote library " + libraryId + " has no version " + libraryVersion)))) + .withStage(String.format("hmcl.install.%s:%s", libraryId, libraryVersion)); + } + + /// Removes one component from an unpublished new instance manifest. + /// + /// @param workingManifest the manifest being edited + /// @param gameVersion the Minecraft version used for component analysis + /// @param componentType the component to remove + /// @return the task producing the updated standalone manifest + private Task removeNewInstanceComponentAsync( + GameInstanceManifest workingManifest, + GameVersionNumber gameVersion, + GameComponentType componentType) { + return Task.supplyAsync(() -> { + GameInstanceManifest standalone = workingManifest.inheritsFrom() == null + ? workingManifest + : repository.resolve(workingManifest).standaloneManifest(); + return GameComponentAnalyzer.analyze(standalone, gameVersion).removeLibrary(componentType); + }); + } + /// Resolves a remote component by id/version and installs it into the working manifest. /// /// @param instance the registered instance being modified 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 c2db4cc5fb8..4f7840edf25 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -18,7 +18,7 @@ package org.jackhuang.hmcl.download; import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstance; +import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameRepositoryDraft; import org.jackhuang.hmcl.task.Task; @@ -34,7 +34,7 @@ /// publishes the completed instance once. /// /// Shared libraries, assets, and download caches may remain after failure. The draft removes the -/// instance directory that it created and never publishes a placeholder instance. +/// instance directory that it created and never publishes an incomplete manifest. @NotNullByDefault public class DefaultGameBuilder extends GameBuilder { @@ -57,7 +57,7 @@ public DefaultDependencyManager getDependencyManager() { /// {@inheritDoc} /// - /// Creates an unpublished working instance, installs the configured game and optional loaders, + /// Retains an unpublished working manifest, installs the configured game and optional loaders, /// stages the completed manifest, and commits it once. Failure or cancellation aborts the draft. /// /// @return the build task @@ -87,21 +87,24 @@ public Task buildAsync() { return Task.supplyAsync(() -> { GameRepositoryDraft draft = repository.openDraft(); activeDraft.set(draft); - return draft.put(new GameInstanceManifest(name)); + GameInstanceManifest manifest = new GameInstanceManifest(name); + draft.put(manifest); + return manifest; }) - .thenComposeAsync(instance -> { - Task libraryTask = Task.supplyAsync(instance::getManifest); + .thenComposeAsync(initialManifest -> { + Task libraryTask = Task.supplyAsync(() -> initialManifest); libraryTask = libraryTask.thenComposeAsync( - libraryTaskHelper(instance, gameVersion, "game", gameVersion)); + libraryTaskHelper(name, gameVersion, "game", gameVersion)); for (Map.Entry entry : toolVersions.entrySet()) { libraryTask = libraryTask.thenComposeAsync( - libraryTaskHelper(instance, gameVersion, entry.getKey(), entry.getValue())); + libraryTaskHelper(name, gameVersion, entry.getKey(), entry.getValue())); } for (RemoteVersion remoteVersion : remoteVersions) { libraryTask = libraryTask.thenComposeAsync(working -> - dependencyManager.installComponentAsync(instance, working, remoteVersion)); + dependencyManager.installNewInstanceComponentAsync( + name, working, gameVersion, remoteVersion)); } return libraryTask.thenApplyAsync(manifest -> { @@ -124,17 +127,17 @@ public Task buildAsync() { /// Returns a step that installs one remote component into the working manifest. /// - /// @param instance the registered instance + /// @param instanceId the unpublished instance id /// @param gameVersion the Minecraft version used to look up the remote list /// @param libraryId the component list id /// @param libraryVersion the component version id /// @return a function from the current working manifest to the install task private ExceptionalFunction, ?> libraryTaskHelper( - GameInstance instance, + GameInstanceID instanceId, String gameVersion, String libraryId, String libraryVersion) { - return working -> dependencyManager.installComponentAsync( - instance, working, gameVersion, libraryId, libraryVersion); + return working -> dependencyManager.installNewInstanceComponentAsync( + instanceId, working, gameVersion, libraryId, libraryVersion); } } 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 d169a968e3d..03dc2c4fd17 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -190,10 +190,10 @@ protected void publishSnapshot(DefaultGameRepositorySnapshot newSnapshot) { }); } - /// Publishes the working snapshot of the repository's active draft. + /// Publishes the immutable successor snapshot of the repository's active draft. /// /// @param draft the active draft - /// @param newSnapshot the draft's working snapshot + /// @param newSnapshot the draft's successor snapshot /// @throws IllegalStateException if `draft` is not the active draft void publishDraftSnapshot( DefaultGameRepositoryDraft draft, @@ -565,6 +565,17 @@ public Path getInstanceJson(GameInstanceID instanceId) { 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 @@ -603,8 +614,8 @@ public Task saveAsync(GameInstanceManifest instanceManifes /// Creates a task that updates one registered instance inside an exclusive draft. /// - /// The updater receives the instance from the draft's unpublished working snapshot and must - /// return a manifest with the same id. Its result is staged and committed exactly once. Failure + /// The updater receives the instance from the immutable published snapshot and must return a + /// working manifest with the same id. Its result is staged and committed exactly once. Failure /// or cancellation aborts the draft; shared cache files written by the updater are retained. /// /// @param the checked exception type thrown while creating the update task @@ -618,7 +629,8 @@ public Task updateInstanceAsync( return Task.supplyAsync(() -> { GameRepositoryDraft draft = openDraft(); active.set(draft); - return draft.getSnapshot().getInstance(instanceId); + GameInstance publishedInstance = getInstance(instanceId); + return publishedInstance; }) .thenComposeAsync(updater) .thenApplyAsync(manifest -> { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java index 7bc63c3d1e5..fdca62a2767 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -39,11 +39,10 @@ /// Default exclusive [GameRepositoryDraft] implementation. /// -/// Manifest changes are reflected in an unpublished working snapshot and serialized below a -/// draft-private directory. Instance installers may use the returned working [GameInstance] and -/// write instance-owned files before commit. A successful commit moves all staged manifests into -/// place and publishes the working snapshot once. Shared library and asset cache writes are outside -/// the rollback boundary. Instances of this class are not thread-safe. +/// Manifest changes are retained as a private write set and serialized below a draft-private +/// directory. A successful commit applies the write set and publishes one new immutable snapshot. +/// Shared library and asset cache writes are outside the rollback boundary. Instances of this class +/// are not thread-safe. @NotNullByDefault public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { @@ -53,8 +52,8 @@ public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { /// Immutable published snapshot captured when this draft was opened. private final DefaultGameRepositorySnapshot baseSnapshot; - /// Mutable snapshot containing the draft's unpublished manifest changes. - private final DefaultGameRepositorySnapshot workingSnapshot; + /// Current unpublished manifests keyed by instance id. + private final Map manifests = new TreeMap<>(); /// Staged manifest files keyed by instance id. private final Map stagedManifests = new TreeMap<>(); @@ -62,7 +61,7 @@ public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { /// Instance ids whose root directories were absent before this draft first staged them. private final Set createdIds = new TreeSet<>(); - /// Instance ids removed from the working snapshot. + /// Instance ids absent from the final manifest set. private final Set removedIds = new TreeSet<>(); /// Ordered instance renames applied to the filesystem during commit. @@ -80,7 +79,9 @@ public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { DefaultGameRepositoryDraft(DefaultGameRepository repository) { this.repository = repository; this.baseSnapshot = repository.getSnapshot(); - this.workingSnapshot = baseSnapshot.clone(); + for (GameInstanceManifest manifest : baseSnapshot.getInstanceManifests()) { + manifests.put(manifest.id(), manifest); + } } /// {@inheritDoc} @@ -89,21 +90,6 @@ public DefaultGameRepository getRepository() { return repository; } - /// {@inheritDoc} - @Override - public GameRepositorySnapshot getBaseSnapshot() { - return baseSnapshot; - } - - /// {@inheritDoc} - @Override - public GameRepositorySnapshot getSnapshot() { - if (state == GameRepositoryDraftState.ABORTED || state == GameRepositoryDraftState.FAILED) { - throw new IllegalStateException("Draft is " + state.name().toLowerCase()); - } - return workingSnapshot; - } - /// {@inheritDoc} @Override public GameRepositoryDraftState getState() { @@ -124,27 +110,19 @@ public boolean isCommitted() { /// {@inheritDoc} @Override - public boolean hasInstance(GameInstanceID instanceId) { + public void put(GameInstanceManifest manifest) throws IOException { checkOpen(); - return workingSnapshot.hasInstance(instanceId); - } - - /// {@inheritDoc} - @Override - public DefaultGameInstance put(GameInstanceManifest manifest) throws IOException { - checkOpen(); - return stageManifest(manifest, true); + stageManifest(manifest, true); } /// {@inheritDoc} @Override public void remove(GameInstanceID instanceId) { checkOpen(); - if (workingSnapshot.get(instanceId) == null) { + if (manifests.remove(instanceId) == null) { throw new NoSuchGameInstanceException(instanceId); } - workingSnapshot.remove(instanceId); stagedManifests.remove(instanceId); removedIds.add(instanceId); } @@ -153,14 +131,14 @@ public void remove(GameInstanceID instanceId) { @Override public void rename(GameInstanceID from, GameInstanceID to) throws IOException { checkOpen(); - DefaultGameInstance source = workingSnapshot.get(from); + GameInstanceManifest source = manifests.get(from); if (source == null) { throw new NoSuchGameInstanceException(from); } if (createdIds.contains(from)) { throw new IllegalStateException("Cannot rename an instance created by the same draft"); } - if (workingSnapshot.get(to) != null) { + if (manifests.containsKey(to)) { throw new IllegalArgumentException("Target instance already exists: " + to); } @@ -169,21 +147,18 @@ public void rename(GameInstanceID from, GameInstanceID to) throws IOException { throw new FileAlreadyExistsException(targetRoot.toString()); } - GameInstanceManifest renamedManifest = source.getManifest(); + GameInstanceManifest renamedManifest = source; if (from.equals(renamedManifest.jar())) { renamedManifest = renamedManifest.withJar(null); } renamedManifest = renamedManifest.withId(to); - workingSnapshot.remove(from); + manifests.remove(from); stagedManifests.remove(from); removedIds.remove(from); - DefaultGameInstance renamed = repository.createInstance(workingSnapshot, to, renamedManifest); - workingSnapshot.put(renamed); stageManifest(renamedManifest, false); - for (DefaultGameInstance instance : List.copyOf(workingSnapshot.values())) { - GameInstanceManifest manifest = instance.getManifest(); + for (GameInstanceManifest manifest : List.copyOf(manifests.values())) { if (from.equals(manifest.inheritsFrom())) { stageManifest(manifest.withInheritsFrom(to), false); } @@ -191,19 +166,17 @@ public void rename(GameInstanceID from, GameInstanceID to) throws IOException { renames.add(new RenameOperation(from, to)); } - /// Stages one manifest and updates the working snapshot. + /// Stages one manifest and updates the draft write set. /// /// @param manifest the manifest to stage /// @param claimNewRoot whether a previously absent instance root should become draft-owned - /// @return the updated working instance /// @throws IOException if the root cannot be claimed or the temporary manifest cannot be written - private DefaultGameInstance stageManifest( + private void stageManifest( GameInstanceManifest manifest, boolean claimNewRoot) throws IOException { GameInstanceID id = manifest.id(); - DefaultGameInstance existing = workingSnapshot.get(id); - if (claimNewRoot && existing == null && !stagedManifests.containsKey(id)) { + if (claimNewRoot && !manifests.containsKey(id) && !stagedManifests.containsKey(id)) { Path root = getValidatedInstanceRoot(id); if (!repository.mayClaimDraftInstanceRoot(id, root)) { throw new FileAlreadyExistsException(root.toString(), null, @@ -217,16 +190,8 @@ private DefaultGameInstance stageManifest( Path targetFile = previous != null ? previous.targetFile() : getManifestTarget(id); Path stagedFile = previous != null ? previous.stagedFile() : createStagedManifestPath(); FileUtils.saveSafely(stagedFile, JsonUtils.GSON.toJson(manifest)); - stagedManifests.put(id, new StagedManifest(stagedFile, targetFile)); - - DefaultGameInstance updated; - if (existing != null) { - updated = existing.withManifest(workingSnapshot, manifest); - } else { - updated = repository.createInstance(workingSnapshot, id, manifest); - } - workingSnapshot.put(updated); - return updated; + stagedManifests.put(id, new StagedManifest(manifest, stagedFile, targetFile)); + manifests.put(id, manifest); } /// {@inheritDoc} @@ -240,6 +205,7 @@ public DefaultGameRepositorySnapshot commit() throws IOException { List removedRoots = new ArrayList<>(); List applied = new ArrayList<>(); try { + DefaultGameRepositorySnapshot committedSnapshot = buildCommittedSnapshot(); for (RenameOperation rename : renames) { applyRename(rename, appliedRenames); } @@ -250,11 +216,11 @@ public DefaultGameRepositorySnapshot commit() throws IOException { applyManifest(entry.getKey(), entry.getValue(), applied); } - repository.publishDraftSnapshot(this, workingSnapshot); + repository.publishDraftSnapshot(this, committedSnapshot); state = GameRepositoryDraftState.COMMITTED; repository.releaseDraft(this); cleanupStagingAfterCommit(); - return workingSnapshot; + return committedSnapshot; } catch (IOException | RuntimeException e) { IOException rollbackFailure = rollbackAppliedManifests(applied); rollbackFailure = accumulateNullable(rollbackFailure, rollbackRemovedRoots(removedRoots)); @@ -272,6 +238,29 @@ public DefaultGameRepositorySnapshot commit() throws IOException { } } + /// Builds the immutable successor snapshot represented by the final manifest write set. + /// + /// @return the sealed snapshot to publish after filesystem changes succeed + private DefaultGameRepositorySnapshot buildCommittedSnapshot() { + DefaultGameRepositorySnapshot committedSnapshot = baseSnapshot.mutableCopy(); + for (RenameOperation rename : renames) { + committedSnapshot.remove(rename.from()); + } + for (GameInstanceID id : removedIds) { + committedSnapshot.remove(id); + } + for (StagedManifest staged : stagedManifests.values()) { + GameInstanceManifest manifest = staged.manifest(); + DefaultGameInstance existing = committedSnapshot.get(manifest.id()); + DefaultGameInstance updated = existing != null + ? existing.withManifest(committedSnapshot, manifest) + : repository.createInstance(committedSnapshot, manifest.id(), manifest); + committedSnapshot.put(updated); + } + committedSnapshot.seal(); + return committedSnapshot; + } + /// {@inheritDoc} @Override public void abort() throws IOException { @@ -595,11 +584,15 @@ private void checkOpen() { } } - /// Records the temporary and permanent paths for one staged manifest. + /// Records one manifest and its temporary and permanent storage paths. /// + /// @param manifest the unpublished manifest value /// @param stagedFile the draft-private serialized manifest /// @param targetFile the permanent repository manifest path - private record StagedManifest(Path stagedFile, Path targetFile) { + private record StagedManifest( + GameInstanceManifest manifest, + Path stagedFile, + Path targetFile) { } /// Records enough information to roll back one manifest replacement. 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 f837a0433a2..93cfe166b35 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositorySnapshot.java @@ -29,15 +29,13 @@ /// Default implementation of a repository index snapshot for [DefaultGameRepository]. /// -/// A snapshot begins unsealed so package-private writers can populate it. [#seal()] freezes the -/// instance map; afterwards any mutating method throws. Repository drafts clone published snapshots, -/// edit the copies, and publish them through the repository's draft commit path. -/// -/// Once sealed, this object is exposed as a [GameRepositorySnapshot]. +/// Package-private repository code assembles an instance in an unpublished construction phase and +/// calls [#seal()] before exposing it as a [GameRepositorySnapshot]. Once sealed, its contents never +/// change. /// /// Mutation methods are package-private: only code in `org.jackhuang.hmcl.game` may assemble a /// snapshot. Subclasses such as HMCL-specific snapshots may override [#newEmpty()] to preserve -/// concrete type through [#clone()], analogous to +/// concrete type through [#mutableCopy()], analogous to /// [DefaultGameInstance#withNewSnapshot(DefaultGameRepositorySnapshot)]. @NotNullByDefault public class DefaultGameRepositorySnapshot implements GameRepositorySnapshot { @@ -149,20 +147,6 @@ public Collection getInstanceManifests() { .toList(); } - /// Returns a view of all instances in this snapshot. - /// - /// @return the instances; unmodifiable after [#seal()] - public Collection values() { - return instances.values(); - } - - /// Returns an unmodifiable map view after seal, or the live map while building. - /// - /// @return the instance map - public Map asMap() { - return instances; - } - /// Adds or replaces an instance in this unsealed snapshot. /// /// @param instance the instance bound to this snapshot @@ -171,14 +155,6 @@ void put(DefaultGameInstance instance) { instances.put(instance.getId(), instance); } - /// Adds or replaces all instances from the given map. - /// - /// @param map instances keyed by id - void putAll(Map map) { - checkMutable(); - instances.putAll(map); - } - /// Removes the instance with the given id. /// /// @param id the instance id @@ -187,11 +163,12 @@ void remove(GameInstanceID id) { instances.remove(id); } - /// Creates an unsealed copy of this snapshot with instances rebound to the copy. + /// Creates an unpublished mutable copy with instances rebound to the copy. /// - /// @return a mutable snapshot ready for further edits before publish - @Override - public DefaultGameRepositorySnapshot clone() { + /// The caller must call [#seal()] before retaining or publishing the result as a snapshot. + /// + /// @return the mutable construction copy + DefaultGameRepositorySnapshot mutableCopy() { DefaultGameRepositorySnapshot newSnapshot = newEmpty(); for (DefaultGameInstance instance : instances.values()) { newSnapshot.put(instance.withNewSnapshot(newSnapshot)); 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 ad9cce612b6..e1741129ff0 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -62,7 +62,7 @@ default Path getBaseDirectory() { /// A repository permits at most one open draft. Repository refreshes, layout changes, and other /// writes are rejected until the draft is committed, aborted, or closed. /// - /// @return a new open draft cloned from [#getSnapshot()] + /// @return a new open draft based on the current published state /// @throws IllegalStateException if this repository is already being modified /// @see GameRepositoryDraft GameRepositoryDraft openDraft(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java index fed115a8437..5542f237508 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -23,10 +23,10 @@ /// Provides the exclusive write session for a game repository. /// -/// A draft owns an unpublished working snapshot initialized from [#getBaseSnapshot()]. Changes made -/// by [#put(GameInstanceManifest)] are immediately visible through [#getSnapshot()] but remain -/// invisible through [GameRepository#getSnapshot()] until [#commit()] succeeds. Manifest JSON is -/// written to draft-private temporary storage and moved into the repository during commit. +/// A draft records an unpublished manifest write set. It does not expose a snapshot or +/// [GameInstance]: callers retain their working [GameInstanceManifest] values while the repository's +/// immutable snapshot remains unchanged until [#commit()] succeeds. Manifest JSON is written to +/// draft-private temporary storage and moved into the repository during commit. /// /// A repository permits at most one open draft. Repository refreshes, layout changes, and other /// writes are rejected while the draft is open. Aborting a draft removes instance directories that @@ -42,18 +42,6 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @return the repository GameRepository getRepository(); - /// Returns the immutable published snapshot captured when this draft was opened. - /// - /// @return the base snapshot - GameRepositorySnapshot getBaseSnapshot(); - - /// Returns the current unpublished working snapshot, or the snapshot published by a successful - /// [#commit()]. - /// - /// @return the working or committed snapshot - /// @throws IllegalStateException if the draft was aborted or failed - GameRepositorySnapshot getSnapshot(); - /// Returns this draft's lifecycle state. /// /// @return the current state @@ -69,36 +57,27 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @return whether [#commit()] has completed successfully boolean isCommitted(); - /// Returns whether the working snapshot contains `instanceId`. - /// - /// @param instanceId the instance id - /// @return whether the id is present in the working snapshot - /// @throws IllegalStateException if the draft is not open - boolean hasInstance(GameInstanceID instanceId); - - /// Adds or replaces a stored manifest in the unpublished working snapshot. + /// Adds or replaces a stored manifest in the unpublished draft state. /// - /// The manifest JSON is written to draft-private temporary storage. The returned [GameInstance] - /// belongs to the working snapshot and may be used by installation code before commit. It may - /// become stale after another call to this method for the same id. + /// The manifest JSON is written to draft-private temporary storage. This operation does not + /// create or expose a [GameInstance]. /// /// @param manifest the persistent instance manifest - /// @return the instance in the updated working snapshot /// @throws IOException if the temporary manifest cannot be written /// @throws IllegalStateException if the draft is not open - GameInstance put(GameInstanceManifest manifest) throws IOException; + void put(GameInstanceManifest manifest) throws IOException; - /// Removes an instance from the unpublished working snapshot. + /// Removes an instance from the unpublished draft state. /// /// Its instance root is retained until commit and moved out of the repository before the new /// snapshot is published. Aborting leaves the published instance and its files unchanged. /// /// @param instanceId the instance to remove - /// @throws NoSuchGameInstanceException if the working snapshot does not contain the instance + /// @throws NoSuchGameInstanceException if the draft does not contain the instance /// @throws IllegalStateException if the draft is not open void remove(GameInstanceID instanceId); - /// Renames an instance in the unpublished working snapshot. + /// Renames an instance in the unpublished draft state. /// /// Direct inheritance references managed by the repository are updated in the same draft. The /// source directory remains at its published location until commit. @@ -107,12 +86,12 @@ public interface GameRepositoryDraft extends AutoCloseable { /// @param to the target instance id /// @throws IOException if the target directory already exists or a temporary /// manifest cannot be written - /// @throws NoSuchGameInstanceException if the working snapshot does not contain `from` - /// @throws IllegalArgumentException if the working snapshot already contains `to` + /// @throws NoSuchGameInstanceException if the draft does not contain `from` + /// @throws IllegalArgumentException if the draft already contains `to` /// @throws IllegalStateException if the draft is not open void rename(GameInstanceID from, GameInstanceID to) throws IOException; - /// Applies staged manifest files and publishes the working snapshot. + /// Applies staged manifest files and publishes a new immutable snapshot. /// /// After this method returns, [GameRepository#getInstance(GameInstanceID)] will resolve staged /// ids from the published index. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java index e87ae824251..6e3529ca243 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java @@ -25,7 +25,7 @@ public enum GameRepositoryDraftState { /// The draft accepts changes and may be committed or aborted. OPEN, - /// The draft is applying staged files and publishing its working snapshot. + /// The draft is applying staged files and publishing its immutable successor snapshot. COMMITTING, /// The draft completed its commit and no longer accepts changes. 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 7401c750b21..26e247b740c 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java @@ -45,7 +45,7 @@ public void testRejectsSpecialPathSegmentIds() { assertThrows(IllegalArgumentException.class, () -> new GameInstanceID("..")); } - /// Keeps a new manifest private until commit and publishes the working instance once committed. + /// Keeps a new manifest private until commit and publishes the resulting instance once committed. @Test public void testCommitPublishesStagedManifest(@TempDir Path tempDirectory) throws IOException { TestRepository repository = new TestRepository(tempDirectory); @@ -54,10 +54,8 @@ public void testCommitPublishesStagedManifest(@TempDir Path tempDirectory) throw Path manifestFile = repository.getLayout().getInstanceJson(id); try (DefaultGameRepositoryDraft draft = repository.openDraft()) { - GameInstance workingInstance = draft.put(manifest); + draft.put(manifest); - assertEquals(manifest, workingInstance.getManifest()); - assertTrue(draft.getSnapshot().hasInstance(id)); assertFalse(repository.hasInstance(id)); assertFalse(Files.exists(manifestFile)); @@ -106,7 +104,6 @@ public void testAbortDoesNotOverwriteExistingManifest(@TempDir Path tempDirector try (DefaultGameRepositoryDraft draft = repository.openDraft()) { draft.put(updated); - assertEquals(updated, draft.getSnapshot().getInstance(id).getManifest()); assertEquals(original, repository.getInstance(id).getManifest()); } @@ -162,7 +159,6 @@ public void testAbortPreservesRemovedInstance(@TempDir Path tempDirectory) throw try (DefaultGameRepositoryDraft draft = repository.openDraft()) { draft.remove(id); - assertFalse(draft.getSnapshot().hasInstance(id)); assertTrue(repository.hasInstance(id)); assertTrue(Files.isDirectory(root)); } @@ -228,7 +224,7 @@ public void testCommitFailureRollsBackAndReleasesDraft(@TempDir Path tempDirecto } } - /// Runs an asynchronous instance update against the unpublished working instance. + /// Runs an asynchronous update using the published instance as immutable context. @Test public void testUpdateInstanceAsyncCommitsWorkingManifest(@TempDir Path tempDirectory) throws Exception { TestRepository repository = new TestRepository(tempDirectory); From 550ce13ffe6a9e53a67f2e3463c1c683be10755f Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 12 Aug 2026 21:56:21 +0800 Subject: [PATCH 166/199] Simplify repository draft manifest handling Assisted-by: codex:gpt-5.6-sol --- .../hmcl/game/DefaultGameRepositoryDraft.java | 133 +++++++----------- .../hmcl/game/GameRepositoryDraft.java | 27 ++-- .../hmcl/game/GameRepositoryDraftState.java | 2 +- .../game/DefaultGameRepositoryDraftTest.java | 10 +- 4 files changed, 73 insertions(+), 99 deletions(-) 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 fdca62a2767..add57005598 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -39,10 +39,10 @@ /// Default exclusive [GameRepositoryDraft] implementation. /// -/// Manifest changes are retained as a private write set and serialized below a draft-private -/// directory. A successful commit applies the write set and publishes one new immutable snapshot. -/// Shared library and asset cache writes are outside the rollback boundary. Instances of this class -/// are not thread-safe. +/// Manifest changes are retained in memory until commit. A successful commit writes the final +/// manifests, applies removals and renames, and publishes one new immutable snapshot. Shared library +/// and asset cache writes are outside the rollback boundary. Instances of this class are not +/// thread-safe. @NotNullByDefault public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { @@ -55,10 +55,10 @@ public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { /// Current unpublished manifests keyed by instance id. private final Map manifests = new TreeMap<>(); - /// Staged manifest files keyed by instance id. - private final Map stagedManifests = new TreeMap<>(); + /// Instance ids whose final manifests differ from the published snapshot. + private final Set modifiedIds = new TreeSet<>(); - /// Instance ids whose root directories were absent before this draft first staged them. + /// Instance ids whose root directories were absent before this draft first added them. private final Set createdIds = new TreeSet<>(); /// Instance ids absent from the final manifest set. @@ -67,7 +67,7 @@ public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { /// Ordered instance renames applied to the filesystem during commit. private final List renames = new ArrayList<>(); - /// Draft-private directory containing staged manifests and commit backups. + /// Draft-private directory containing removed roots and commit backups. private @Nullable Path stagingDirectory; /// Current lifecycle state. @@ -112,7 +112,7 @@ public boolean isCommitted() { @Override public void put(GameInstanceManifest manifest) throws IOException { checkOpen(); - stageManifest(manifest, true); + putManifest(manifest, true); } /// {@inheritDoc} @@ -123,7 +123,7 @@ public void remove(GameInstanceID instanceId) { throw new NoSuchGameInstanceException(instanceId); } - stagedManifests.remove(instanceId); + modifiedIds.remove(instanceId); removedIds.add(instanceId); } @@ -131,7 +131,7 @@ public void remove(GameInstanceID instanceId) { @Override public void rename(GameInstanceID from, GameInstanceID to) throws IOException { checkOpen(); - GameInstanceManifest source = manifests.get(from); + @Nullable GameInstanceManifest source = manifests.get(from); if (source == null) { throw new NoSuchGameInstanceException(from); } @@ -154,29 +154,29 @@ public void rename(GameInstanceID from, GameInstanceID to) throws IOException { renamedManifest = renamedManifest.withId(to); manifests.remove(from); - stagedManifests.remove(from); + modifiedIds.remove(from); removedIds.remove(from); - stageManifest(renamedManifest, false); + putManifest(renamedManifest, false); for (GameInstanceManifest manifest : List.copyOf(manifests.values())) { if (from.equals(manifest.inheritsFrom())) { - stageManifest(manifest.withInheritsFrom(to), false); + putManifest(manifest.withInheritsFrom(to), false); } } renames.add(new RenameOperation(from, to)); } - /// Stages one manifest and updates the draft write set. + /// Updates one manifest in the in-memory write set. /// - /// @param manifest the manifest to stage + /// @param manifest the manifest to retain /// @param claimNewRoot whether a previously absent instance root should become draft-owned - /// @throws IOException if the root cannot be claimed or the temporary manifest cannot be written - private void stageManifest( + /// @throws IOException if a new instance root cannot be claimed or initialized + private void putManifest( GameInstanceManifest manifest, boolean claimNewRoot) throws IOException { GameInstanceID id = manifest.id(); - if (claimNewRoot && !manifests.containsKey(id) && !stagedManifests.containsKey(id)) { + if (claimNewRoot && !manifests.containsKey(id)) { Path root = getValidatedInstanceRoot(id); if (!repository.mayClaimDraftInstanceRoot(id, root)) { throw new FileAlreadyExistsException(root.toString(), null, @@ -186,12 +186,8 @@ private void stageManifest( repository.initializeDraftInstanceRoot(id, root); } - StagedManifest previous = stagedManifests.get(id); - Path targetFile = previous != null ? previous.targetFile() : getManifestTarget(id); - Path stagedFile = previous != null ? previous.stagedFile() : createStagedManifestPath(); - FileUtils.saveSafely(stagedFile, JsonUtils.GSON.toJson(manifest)); - stagedManifests.put(id, new StagedManifest(manifest, stagedFile, targetFile)); manifests.put(id, manifest); + modifiedIds.add(id); } /// {@inheritDoc} @@ -201,7 +197,7 @@ public DefaultGameRepositorySnapshot commit() throws IOException { repository.checkActiveDraft(this); state = GameRepositoryDraftState.COMMITTING; - List appliedRenames = new ArrayList<>(); + List appliedRenames = new ArrayList<>(); List removedRoots = new ArrayList<>(); List applied = new ArrayList<>(); try { @@ -212,8 +208,12 @@ public DefaultGameRepositorySnapshot commit() throws IOException { for (GameInstanceID id : removedIds) { removeInstanceRoot(id, removedRoots); } - for (Map.Entry entry : stagedManifests.entrySet()) { - applyManifest(entry.getKey(), entry.getValue(), applied); + for (GameInstanceID id : modifiedIds) { + @Nullable GameInstanceManifest manifest = manifests.get(id); + if (manifest == null) { + throw new IllegalStateException("Modified manifest is missing: " + id); + } + applyManifest(id, manifest, applied); } repository.publishDraftSnapshot(this, committedSnapshot); @@ -249,9 +249,12 @@ private DefaultGameRepositorySnapshot buildCommittedSnapshot() { for (GameInstanceID id : removedIds) { committedSnapshot.remove(id); } - for (StagedManifest staged : stagedManifests.values()) { - GameInstanceManifest manifest = staged.manifest(); - DefaultGameInstance existing = committedSnapshot.get(manifest.id()); + for (GameInstanceID id : modifiedIds) { + @Nullable GameInstanceManifest manifest = manifests.get(id); + if (manifest == null) { + throw new IllegalStateException("Modified manifest is missing: " + id); + } + @Nullable DefaultGameInstance existing = committedSnapshot.get(manifest.id()); DefaultGameInstance updated = existing != null ? existing.withManifest(committedSnapshot, manifest) : repository.createInstance(committedSnapshot, manifest.id(), manifest); @@ -301,22 +304,12 @@ public void close() throws IOException { /// @param id the instance id /// @return the permanent manifest path private Path getManifestTarget(GameInstanceID id) { - DefaultGameInstance existing = baseSnapshot.get(id); + @Nullable DefaultGameInstance existing = baseSnapshot.get(id); return (existing != null ? existing.getManifestFile() : baseSnapshot.getLayout().getInstanceJson(id)) .toAbsolutePath() .normalize(); } - /// Creates a unique path for a staged manifest. - /// - /// @return the staged manifest path - /// @throws IOException if the staging directory cannot be created - private Path createStagedManifestPath() throws IOException { - Path manifests = getOrCreateStagingDirectory().resolve("manifests"); - Files.createDirectories(manifests); - return Files.createTempFile(manifests, "manifest-", ".json"); - } - /// Returns the draft-private staging directory, creating it when necessary. /// /// @return the staging directory @@ -342,7 +335,7 @@ private Path getOrCreateStagingDirectory() throws IOException { /// @param rename the requested rename /// @param applied rollback records for completed renames /// @throws IOException if the source files cannot be renamed - private void applyRename(RenameOperation rename, List applied) throws IOException { + private void applyRename(RenameOperation rename, List applied) throws IOException { Path sourceRoot = getValidatedInstanceRoot(rename.from()); Path targetRoot = getValidatedInstanceRoot(rename.to()); if (!Files.isDirectory(sourceRoot)) { @@ -356,7 +349,7 @@ private void applyRename(RenameOperation rename, List applied) th baseSnapshot.getLayout().getBaseDirectory(), rename.from(), rename.to()); - applied.add(new AppliedRename(rename.from(), rename.to())); + applied.add(rename); } /// Moves one removed instance root into the draft staging directory. @@ -378,26 +371,26 @@ private void removeInstanceRoot(GameInstanceID id, List removed) th removed.add(new RemovedRoot(root, stagedRoot)); } - /// Moves one staged manifest into place while retaining a rollback copy. + /// Writes one manifest while retaining a rollback copy. /// - /// @param id the instance id - /// @param staged the staged and target paths - /// @param applied rollback records for changes already started + /// @param id the instance whose manifest will be replaced + /// @param manifest the final manifest + /// @param applied rollback records for changes already started /// @throws IOException if the target cannot be backed up or replaced private void applyManifest( GameInstanceID id, - StagedManifest staged, + GameInstanceManifest manifest, List applied) throws IOException { - Path target = staged.targetFile(); + String json = JsonUtils.GSON.toJson(manifest); + Path target = getManifestTarget(id); Path expectedRoot = getValidatedInstanceRoot(id); if (target.equals(expectedRoot) || !target.startsWith(expectedRoot)) { throw new IOException("Manifest path escapes instance root: " + target); } Files.createDirectories(target.getParent()); - boolean hadOriginal = Files.exists(target); @Nullable Path backup = null; - if (hadOriginal) { + if (Files.exists(target)) { Path backups = getOrCreateStagingDirectory().resolve("backups"); Files.createDirectories(backups); backup = Files.createTempFile(backups, "manifest-", ".json"); @@ -405,8 +398,8 @@ private void applyManifest( moveReplacing(target, backup); } - applied.add(new AppliedManifest(target, backup, hadOriginal)); - moveReplacing(staged.stagedFile(), target); + applied.add(new AppliedManifest(target, backup)); + Files.writeString(target, json); } /// Restores manifests changed by an unsuccessful commit in reverse application order. @@ -420,7 +413,7 @@ private void applyManifest( for (AppliedManifest manifest : reversed) { try { Files.deleteIfExists(manifest.targetFile()); - if (manifest.hadOriginal() && manifest.backupFile() != null) { + if (manifest.backupFile() != null) { moveReplacing(manifest.backupFile(), manifest.targetFile()); } } catch (IOException e) { @@ -452,11 +445,11 @@ private void applyManifest( /// /// @param applied completed rename records /// @return the aggregated rollback failure, or `null` when rollback succeeded - private @Nullable IOException rollbackRenames(List applied) { + private @Nullable IOException rollbackRenames(List applied) { @Nullable IOException failure = null; - List reversed = new ArrayList<>(applied); + List reversed = new ArrayList<>(applied); Collections.reverse(reversed); - for (AppliedRename rename : reversed) { + for (RenameOperation rename : reversed) { try { DefaultGameRepository.moveInstanceFiles( baseSnapshot.getLayout().getBaseDirectory(), @@ -584,26 +577,13 @@ private void checkOpen() { } } - /// Records one manifest and its temporary and permanent storage paths. - /// - /// @param manifest the unpublished manifest value - /// @param stagedFile the draft-private serialized manifest - /// @param targetFile the permanent repository manifest path - private record StagedManifest( - GameInstanceManifest manifest, - Path stagedFile, - Path targetFile) { - } - /// Records enough information to roll back one manifest replacement. /// - /// @param targetFile the permanent manifest path - /// @param backupFile the prior manifest backup, or `null` when no prior file existed - /// @param hadOriginal whether the permanent manifest existed before commit + /// @param targetFile the permanent manifest path + /// @param backupFile the prior manifest backup, or `null` when no prior file existed private record AppliedManifest( Path targetFile, - @Nullable Path backupFile, - boolean hadOriginal) { + @Nullable Path backupFile) { } /// Records an instance rename requested by the draft. @@ -613,13 +593,6 @@ private record AppliedManifest( private record RenameOperation(GameInstanceID from, GameInstanceID to) { } - /// Records an instance rename completed during commit. - /// - /// @param from the original instance id - /// @param to the renamed instance id - private record AppliedRename(GameInstanceID from, GameInstanceID to) { - } - /// Records an instance root moved into staging during commit. /// /// @param originalRoot the published instance root diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java index 5542f237508..55fea096d05 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -25,8 +25,8 @@ /// /// A draft records an unpublished manifest write set. It does not expose a snapshot or /// [GameInstance]: callers retain their working [GameInstanceManifest] values while the repository's -/// immutable snapshot remains unchanged until [#commit()] succeeds. Manifest JSON is written to -/// draft-private temporary storage and moved into the repository during commit. +/// immutable snapshot remains unchanged until [#commit()] succeeds. Manifest JSON is written only +/// during commit. /// /// A repository permits at most one open draft. Repository refreshes, layout changes, and other /// writes are rejected while the draft is open. Aborting a draft removes instance directories that @@ -59,11 +59,12 @@ public interface GameRepositoryDraft extends AutoCloseable { /// Adds or replaces a stored manifest in the unpublished draft state. /// - /// The manifest JSON is written to draft-private temporary storage. This operation does not - /// create or expose a [GameInstance]. + /// The manifest is retained in memory until commit. This operation does not serialize the + /// manifest or create or expose a [GameInstance]. For a new id, the draft claims its instance + /// root and may initialize repository-specific files there. /// /// @param manifest the persistent instance manifest - /// @throws IOException if the temporary manifest cannot be written + /// @throws IOException if a new instance root cannot be claimed or initialized /// @throws IllegalStateException if the draft is not open void put(GameInstanceManifest manifest) throws IOException; @@ -84,28 +85,26 @@ public interface GameRepositoryDraft extends AutoCloseable { /// /// @param from the current instance id /// @param to the target instance id - /// @throws IOException if the target directory already exists or a temporary - /// manifest cannot be written + /// @throws IOException if the target root is invalid or already exists /// @throws NoSuchGameInstanceException if the draft does not contain `from` /// @throws IllegalArgumentException if the draft already contains `to` /// @throws IllegalStateException if the draft is not open void rename(GameInstanceID from, GameInstanceID to) throws IOException; - /// Applies staged manifest files and publishes a new immutable snapshot. + /// Writes modified manifest files, then publishes a new immutable snapshot. /// - /// After this method returns, [GameRepository#getInstance(GameInstanceID)] will resolve staged + /// After this method returns, [GameRepository#getInstance(GameInstanceID)] will resolve modified /// ids from the published index. /// /// @return the newly published snapshot - /// @throws IOException if staged filesystem changes cannot be applied + /// @throws IOException if filesystem changes cannot be applied /// @throws IllegalStateException if the draft is not open or is not the repository's active draft GameRepositorySnapshot commit() throws IOException; - /// Discards staged changes without publishing a new snapshot. + /// Discards pending changes without publishing a new snapshot. /// - /// Removes temporary manifests and instance directories created only by this draft. Global - /// caches (libraries, assets) are not reverted. This method is idempotent after a successful - /// abort. + /// Removes instance directories created only by this draft. Global caches (libraries, assets) + /// are not reverted. This method is idempotent after a successful abort. /// /// @throws IOException if cleanup fails /// @throws IllegalStateException if the draft was already committed diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java index 6e3529ca243..1576ff26ebd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java @@ -25,7 +25,7 @@ public enum GameRepositoryDraftState { /// The draft accepts changes and may be committed or aborted. OPEN, - /// The draft is applying staged files and publishing its immutable successor snapshot. + /// The draft is applying files and publishing its immutable successor snapshot. COMMITTING, /// The draft completed its commit and no longer accepts changes. 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 26e247b740c..7fd8d42abb0 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java @@ -45,19 +45,21 @@ public void testRejectsSpecialPathSegmentIds() { assertThrows(IllegalArgumentException.class, () -> new GameInstanceID("..")); } - /// Keeps a new manifest private until commit and publishes the resulting instance once committed. + /// Keeps a new manifest in memory until commit and publishes the resulting instance once committed. @Test - public void testCommitPublishesStagedManifest(@TempDir Path tempDirectory) throws IOException { + public void testCommitPublishesModifiedManifest(@TempDir Path tempDirectory) throws IOException { TestRepository repository = new TestRepository(tempDirectory); GameInstanceID id = new GameInstanceID("instance"); GameInstanceManifest manifest = new GameInstanceManifest(id).withMainClass("example.Main"); Path manifestFile = repository.getLayout().getInstanceJson(id); + Path draftStorage = tempDirectory.resolve(".hmcl").resolve("repository-drafts"); try (DefaultGameRepositoryDraft draft = repository.openDraft()) { draft.put(manifest); assertFalse(repository.hasInstance(id)); assertFalse(Files.exists(manifestFile)); + assertFalse(Files.exists(draftStorage)); GameRepositorySnapshot committed = draft.commit(); assertEquals(GameRepositoryDraftState.COMMITTED, draft.getState()); @@ -149,7 +151,7 @@ public void testPutRejectsPreexistingUnregisteredDirectory(@TempDir Path tempDir assertTrue(Files.exists(retained)); } - /// Keeps a staged removal private and preserves the published files when the draft aborts. + /// Keeps a pending removal private and preserves the published files when the draft aborts. @Test public void testAbortPreservesRemovedInstance(@TempDir Path tempDirectory) throws IOException { TestRepository repository = new TestRepository(tempDirectory); @@ -200,7 +202,7 @@ public void testRemoveInstanceUsesDraftCommit(@TempDir Path tempDirectory) throw assertFalse(Files.exists(root)); } - /// Restores staged manifests and releases exclusivity when publication fails before replacement. + /// Restores modified manifests and releases exclusivity when publication fails after replacement. @Test public void testCommitFailureRollsBackAndReleasesDraft(@TempDir Path tempDirectory) throws IOException { FailingRepository repository = new FailingRepository(tempDirectory); From a9da320b9ab8ec445b026f3d677e5baf9afdd6e3 Mon Sep 17 00:00:00 2001 From: Glavo Date: Wed, 12 Aug 2026 21:58:36 +0800 Subject: [PATCH 167/199] Simplify inherited manifest updates during rename Assisted-by: codex:gpt-5.6-sol --- .../hmcl/game/DefaultGameRepositoryDraft.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 add57005598..e0d251cf23c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -158,11 +158,13 @@ public void rename(GameInstanceID from, GameInstanceID to) throws IOException { removedIds.remove(from); putManifest(renamedManifest, false); - for (GameInstanceManifest manifest : List.copyOf(manifests.values())) { - if (from.equals(manifest.inheritsFrom())) { - putManifest(manifest.withInheritsFrom(to), false); + manifests.replaceAll((id, manifest) -> { + if (!from.equals(manifest.inheritsFrom())) { + return manifest; } - } + modifiedIds.add(id); + return manifest.withInheritsFrom(to); + }); renames.add(new RenameOperation(from, to)); } From b5d17a01309018af7910be4caa60cee48687ad90 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 13 Aug 2026 20:14:24 +0800 Subject: [PATCH 168/199] Localize repository draft rollback storage Assisted-by: codex:gpt-5.6-sol --- .../hmcl/game/DefaultGameRepositoryDraft.java | 128 ++++++++++-------- 1 file changed, 70 insertions(+), 58 deletions(-) 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 e0d251cf23c..9dfe7f9a11b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -67,9 +67,6 @@ public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { /// Ordered instance renames applied to the filesystem during commit. private final List renames = new ArrayList<>(); - /// Draft-private directory containing removed roots and commit backups. - private @Nullable Path stagingDirectory; - /// Current lifecycle state. private GameRepositoryDraftState state = GameRepositoryDraftState.OPEN; @@ -202,26 +199,32 @@ public DefaultGameRepositorySnapshot commit() throws IOException { List appliedRenames = new ArrayList<>(); List removedRoots = new ArrayList<>(); List applied = new ArrayList<>(); + @Nullable Path rollbackDirectory = null; try { DefaultGameRepositorySnapshot committedSnapshot = buildCommittedSnapshot(); for (RenameOperation rename : renames) { applyRename(rename, appliedRenames); } - for (GameInstanceID id : removedIds) { - removeInstanceRoot(id, removedRoots); - } - for (GameInstanceID id : modifiedIds) { - @Nullable GameInstanceManifest manifest = manifests.get(id); - if (manifest == null) { - throw new IllegalStateException("Modified manifest is missing: " + id); + + if (!removedIds.isEmpty() || !modifiedIds.isEmpty()) { + Path currentRollbackDirectory = createRollbackDirectory(); + rollbackDirectory = currentRollbackDirectory; + for (GameInstanceID id : removedIds) { + removeInstanceRoot(id, currentRollbackDirectory, removedRoots); + } + for (GameInstanceID id : modifiedIds) { + @Nullable GameInstanceManifest manifest = manifests.get(id); + if (manifest == null) { + throw new IllegalStateException("Modified manifest is missing: " + id); + } + applyManifest(id, manifest, currentRollbackDirectory, applied); } - applyManifest(id, manifest, applied); } repository.publishDraftSnapshot(this, committedSnapshot); state = GameRepositoryDraftState.COMMITTED; repository.releaseDraft(this); - cleanupStagingAfterCommit(); + cleanupRollbackDirectoryAfterCommit(rollbackDirectory); return committedSnapshot; } catch (IOException | RuntimeException e) { IOException rollbackFailure = rollbackAppliedManifests(applied); @@ -229,7 +232,8 @@ public DefaultGameRepositorySnapshot commit() throws IOException { rollbackFailure = accumulateNullable(rollbackFailure, rollbackRenames(appliedRenames)); state = GameRepositoryDraftState.FAILED; repository.releaseDraft(this); - IOException cleanupFailure = cleanupOwnedFiles(); + IOException cleanupFailure = cleanupCreatedInstanceRoots(); + cleanupFailure = accumulateNullable(cleanupFailure, cleanupRollbackDirectory(rollbackDirectory)); if (rollbackFailure != null) { e.addSuppressed(rollbackFailure); } @@ -282,7 +286,7 @@ public void abort() throws IOException { return; } - IOException failure = cleanupOwnedFiles(); + IOException failure = cleanupCreatedInstanceRoots(); state = failure == null ? GameRepositoryDraftState.ABORTED : GameRepositoryDraftState.FAILED; repository.releaseDraft(this); if (failure != null) { @@ -312,24 +316,18 @@ private Path getManifestTarget(GameInstanceID id) { .normalize(); } - /// Returns the draft-private staging directory, creating it when necessary. + /// Creates a directory for rollback data produced by the current commit attempt. /// - /// @return the staging directory + /// @return the new rollback directory /// @throws IOException if the directory cannot be created - private Path getOrCreateStagingDirectory() throws IOException { - Path current = stagingDirectory; - if (current != null) { - return current; - } - + private Path createRollbackDirectory() throws IOException { Path parent = baseSnapshot.getLayout().getBaseDirectory() .toAbsolutePath() .normalize() .resolve(".hmcl") .resolve("repository-drafts"); Files.createDirectories(parent); - stagingDirectory = Files.createTempDirectory(parent, "draft-"); - return stagingDirectory; + return Files.createTempDirectory(parent, "commit-"); } /// Applies one instance directory rename. @@ -354,34 +352,40 @@ private void applyRename(RenameOperation rename, List applied) applied.add(rename); } - /// Moves one removed instance root into the draft staging directory. + /// Moves one removed instance root into the commit rollback directory. /// - /// @param id the removed instance id - /// @param removed rollback records for roots moved out of the repository - /// @throws IOException if the root cannot be staged - private void removeInstanceRoot(GameInstanceID id, List removed) throws IOException { + /// @param id the removed instance id + /// @param rollbackDirectory the directory owned by the current commit attempt + /// @param removed rollback records for roots moved out of the repository + /// @throws IOException if the root cannot be moved into the rollback directory + private void removeInstanceRoot( + GameInstanceID id, + Path rollbackDirectory, + List removed) throws IOException { Path root = getValidatedInstanceRoot(id); if (Files.notExists(root)) { return; } - Path removals = getOrCreateStagingDirectory().resolve("removed"); + Path removals = rollbackDirectory.resolve("removed"); Files.createDirectories(removals); - Path stagedRoot = Files.createTempDirectory(removals, "instance-"); - Files.delete(stagedRoot); - moveReplacing(root, stagedRoot); - removed.add(new RemovedRoot(root, stagedRoot)); + Path rollbackRoot = Files.createTempDirectory(removals, "instance-"); + Files.delete(rollbackRoot); + moveReplacing(root, rollbackRoot); + removed.add(new RemovedRoot(root, rollbackRoot)); } /// Writes one manifest while retaining a rollback copy. /// - /// @param id the instance whose manifest will be replaced - /// @param manifest the final manifest - /// @param applied rollback records for changes already started + /// @param id the instance whose manifest will be replaced + /// @param manifest the final manifest + /// @param rollbackDirectory the directory owned by the current commit attempt + /// @param applied rollback records for changes already started /// @throws IOException if the target cannot be backed up or replaced private void applyManifest( GameInstanceID id, GameInstanceManifest manifest, + Path rollbackDirectory, List applied) throws IOException { String json = JsonUtils.GSON.toJson(manifest); Path target = getManifestTarget(id); @@ -393,7 +397,7 @@ private void applyManifest( Files.createDirectories(target.getParent()); @Nullable Path backup = null; if (Files.exists(target)) { - Path backups = getOrCreateStagingDirectory().resolve("backups"); + Path backups = rollbackDirectory.resolve("backups"); Files.createDirectories(backups); backup = Files.createTempFile(backups, "manifest-", ".json"); Files.delete(backup); @@ -435,7 +439,7 @@ private void applyManifest( Collections.reverse(reversed); for (RemovedRoot root : reversed) { try { - moveReplacing(root.stagedRoot(), root.originalRoot()); + moveReplacing(root.rollbackRoot(), root.originalRoot()); } catch (IOException e) { failure = accumulate(failure, e); } @@ -464,10 +468,10 @@ private void applyManifest( return failure; } - /// Removes draft-owned instance roots and temporary files. + /// Removes instance roots first created by this draft. /// /// @return the aggregated cleanup failure, or `null` when cleanup succeeded - private @Nullable IOException cleanupOwnedFiles() { + private @Nullable IOException cleanupCreatedInstanceRoots() { @Nullable IOException failure = null; for (GameInstanceID id : createdIds) { try { @@ -482,28 +486,36 @@ private void applyManifest( failure = accumulate(failure, cleanupException); } } + return failure; + } - Path currentStagingDirectory = stagingDirectory; - if (currentStagingDirectory != null) { - try { - FileUtils.deleteDirectory(currentStagingDirectory); - } catch (IOException e) { - failure = accumulate(failure, e); - } + /// Removes a commit rollback directory. + /// + /// @param rollbackDirectory the directory to remove, or `null` if none was created + /// @return the cleanup failure, or `null` when cleanup succeeded + private static @Nullable IOException cleanupRollbackDirectory(@Nullable Path rollbackDirectory) { + if (rollbackDirectory == null) { + return null; + } + try { + FileUtils.deleteDirectory(rollbackDirectory); + return null; + } catch (IOException e) { + return e; } - return failure; } - /// Removes temporary files after a successful commit without changing its outcome. - private void cleanupStagingAfterCommit() { - Path currentStagingDirectory = stagingDirectory; - if (currentStagingDirectory == null) { + /// Removes rollback data after a successful commit without changing its outcome. + /// + /// @param rollbackDirectory the directory to remove, or `null` if none was created + private static void cleanupRollbackDirectoryAfterCommit(@Nullable Path rollbackDirectory) { + if (rollbackDirectory == null) { return; } try { - FileUtils.deleteDirectory(currentStagingDirectory); + FileUtils.deleteDirectory(rollbackDirectory); } catch (IOException e) { - LOG.warning("Failed to remove committed draft staging directory " + currentStagingDirectory, e); + LOG.warning("Failed to remove commit rollback directory " + rollbackDirectory, e); } } @@ -595,10 +607,10 @@ private record AppliedManifest( private record RenameOperation(GameInstanceID from, GameInstanceID to) { } - /// Records an instance root moved into staging during commit. + /// Records an instance root moved aside for rollback during commit. /// /// @param originalRoot the published instance root - /// @param stagedRoot the temporary removal path - private record RemovedRoot(Path originalRoot, Path stagedRoot) { + /// @param rollbackRoot the temporary rollback path + private record RemovedRoot(Path originalRoot, Path rollbackRoot) { } } From ea4ee3caaea3a88c4298c0ee49c55011f4c687bf Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 13 Aug 2026 20:21:42 +0800 Subject: [PATCH 169/199] Nest repository draft state in the draft API Assisted-by: codex:gpt-5.6-sol --- .../hmcl/game/DefaultGameRepositoryDraft.java | 28 ++++++------- .../hmcl/game/GameRepositoryDraft.java | 21 +++++++++- .../hmcl/game/GameRepositoryDraftState.java | 39 ------------------- .../game/DefaultGameRepositoryDraftTest.java | 4 +- 4 files changed, 36 insertions(+), 56 deletions(-) delete mode 100644 HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java 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 9dfe7f9a11b..c5c2d6e8b38 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -68,7 +68,7 @@ public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { private final List renames = new ArrayList<>(); /// Current lifecycle state. - private GameRepositoryDraftState state = GameRepositoryDraftState.OPEN; + private GameRepositoryDraft.State state = GameRepositoryDraft.State.OPEN; /// Creates an open draft over the repository's current published snapshot. /// @@ -89,20 +89,20 @@ public DefaultGameRepository getRepository() { /// {@inheritDoc} @Override - public GameRepositoryDraftState getState() { + public GameRepositoryDraft.State getState() { return state; } /// {@inheritDoc} @Override public boolean isOpen() { - return state == GameRepositoryDraftState.OPEN; + return state == GameRepositoryDraft.State.OPEN; } /// {@inheritDoc} @Override public boolean isCommitted() { - return state == GameRepositoryDraftState.COMMITTED; + return state == GameRepositoryDraft.State.COMMITTED; } /// {@inheritDoc} @@ -194,7 +194,7 @@ private void putManifest( public DefaultGameRepositorySnapshot commit() throws IOException { checkOpen(); repository.checkActiveDraft(this); - state = GameRepositoryDraftState.COMMITTING; + state = GameRepositoryDraft.State.COMMITTING; List appliedRenames = new ArrayList<>(); List removedRoots = new ArrayList<>(); @@ -222,7 +222,7 @@ public DefaultGameRepositorySnapshot commit() throws IOException { } repository.publishDraftSnapshot(this, committedSnapshot); - state = GameRepositoryDraftState.COMMITTED; + state = GameRepositoryDraft.State.COMMITTED; repository.releaseDraft(this); cleanupRollbackDirectoryAfterCommit(rollbackDirectory); return committedSnapshot; @@ -230,7 +230,7 @@ public DefaultGameRepositorySnapshot commit() throws IOException { IOException rollbackFailure = rollbackAppliedManifests(applied); rollbackFailure = accumulateNullable(rollbackFailure, rollbackRemovedRoots(removedRoots)); rollbackFailure = accumulateNullable(rollbackFailure, rollbackRenames(appliedRenames)); - state = GameRepositoryDraftState.FAILED; + state = GameRepositoryDraft.State.FAILED; repository.releaseDraft(this); IOException cleanupFailure = cleanupCreatedInstanceRoots(); cleanupFailure = accumulateNullable(cleanupFailure, cleanupRollbackDirectory(rollbackDirectory)); @@ -273,21 +273,21 @@ private DefaultGameRepositorySnapshot buildCommittedSnapshot() { /// {@inheritDoc} @Override public void abort() throws IOException { - if (state == GameRepositoryDraftState.ABORTED) { + if (state == GameRepositoryDraft.State.ABORTED) { return; } - if (state == GameRepositoryDraftState.COMMITTED) { + if (state == GameRepositoryDraft.State.COMMITTED) { throw new IllegalStateException("Draft is already committed"); } - if (state == GameRepositoryDraftState.COMMITTING) { + if (state == GameRepositoryDraft.State.COMMITTING) { throw new IllegalStateException("Draft is committing"); } - if (state == GameRepositoryDraftState.FAILED) { + if (state == GameRepositoryDraft.State.FAILED) { return; } IOException failure = cleanupCreatedInstanceRoots(); - state = failure == null ? GameRepositoryDraftState.ABORTED : GameRepositoryDraftState.FAILED; + state = failure == null ? GameRepositoryDraft.State.ABORTED : GameRepositoryDraft.State.FAILED; repository.releaseDraft(this); if (failure != null) { throw failure; @@ -297,7 +297,7 @@ public void abort() throws IOException { /// {@inheritDoc} @Override public void close() throws IOException { - if (state == GameRepositoryDraftState.OPEN) { + if (state == GameRepositoryDraft.State.OPEN) { abort(); } } @@ -586,7 +586,7 @@ private static IOException accumulate(@Nullable IOException current, IOException /// /// @throws IllegalStateException if the draft is not open private void checkOpen() { - if (state != GameRepositoryDraftState.OPEN) { + if (state != GameRepositoryDraft.State.OPEN) { throw new IllegalStateException("Draft is " + state.name().toLowerCase()); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java index 55fea096d05..9967a571208 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -37,6 +37,25 @@ @NotNullByDefault public interface GameRepositoryDraft extends AutoCloseable { + /// Describes a draft's lifecycle state. + @NotNullByDefault + enum State { + /// The draft accepts changes and may be committed or aborted. + OPEN, + + /// The draft is applying files and publishing its immutable successor snapshot. + COMMITTING, + + /// The draft completed its commit and no longer accepts changes. + COMMITTED, + + /// The draft discarded its changes and no longer accepts changes. + ABORTED, + + /// The draft could not complete a commit or cleanup operation. + FAILED + } + /// Returns the repository that owns this draft. /// /// @return the repository @@ -45,7 +64,7 @@ public interface GameRepositoryDraft extends AutoCloseable { /// Returns this draft's lifecycle state. /// /// @return the current state - GameRepositoryDraftState getState(); + State getState(); /// Returns whether this draft still accepts mutations. /// diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java deleted file mode 100644 index 1576ff26ebd..00000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraftState.java +++ /dev/null @@ -1,39 +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.game; - -import org.jetbrains.annotations.NotNullByDefault; - -/// Describes the lifecycle state of a [GameRepositoryDraft]. -@NotNullByDefault -public enum GameRepositoryDraftState { - /// The draft accepts changes and may be committed or aborted. - OPEN, - - /// The draft is applying files and publishing its immutable successor snapshot. - COMMITTING, - - /// The draft completed its commit and no longer accepts changes. - COMMITTED, - - /// The draft discarded its changes and no longer accepts changes. - ABORTED, - - /// The draft could not complete a commit or cleanup operation. - FAILED -} 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 7fd8d42abb0..2c65215d501 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java @@ -62,7 +62,7 @@ public void testCommitPublishesModifiedManifest(@TempDir Path tempDirectory) thr assertFalse(Files.exists(draftStorage)); GameRepositorySnapshot committed = draft.commit(); - assertEquals(GameRepositoryDraftState.COMMITTED, draft.getState()); + assertEquals(GameRepositoryDraft.State.COMMITTED, draft.getState()); assertEquals(manifest, committed.getInstance(id).getManifest()); } @@ -215,7 +215,7 @@ public void testCommitFailureRollsBackAndReleasesDraft(@TempDir Path tempDirecto draft.put(original.withMainClass("updated.Main")); assertThrows(IllegalStateException.class, draft::commit); - assertEquals(GameRepositoryDraftState.FAILED, draft.getState()); + assertEquals(GameRepositoryDraft.State.FAILED, draft.getState()); assertEquals(original, repository.getInstance(id).getManifest()); GameInstanceManifest stored = JsonUtils.fromNonNullJson( Files.readString(repository.getLayout().getInstanceJson(id)), From e3619bb61b705837fca02fb7337188e5b6fdde42 Mon Sep 17 00:00:00 2001 From: Glavo Date: Thu, 13 Aug 2026 20:52:24 +0800 Subject: [PATCH 170/199] fix: update parameter name in install method for clarity --- .../hmcl/download/neoforge/NeoForgeInstallTask.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java index b978ff3d2d1..3d82f8efc42 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java @@ -100,8 +100,8 @@ public void execute() throws Exception { dependency = install(dependencyManager, manifest, installer); } - public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest version, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(version); + public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, Path installer) throws IOException, VersionMismatchException { + Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(manifest); if (gameVersion.isEmpty()) throw new IOException(); try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); @@ -110,7 +110,7 @@ public static Task install(DefaultDependencyManager dependenc ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.get().equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); - return new ForgeNewInstallTask(dependencyManager, version, modifyNeoForgeOldVersion(gameVersion.get(), profile.getVersion()), installer).thenApplyAsync(neoForgeVersion -> { + return new ForgeNewInstallTask(dependencyManager, manifest, modifyNeoForgeOldVersion(gameVersion.get(), profile.getVersion()), installer).thenApplyAsync(neoForgeVersion -> { if (!neoForgeVersion.id().equals(GameComponentType.FORGE.getPatchId()) || neoForgeVersion.version() == null) { throw new IOException("Invalid neoforge version."); } @@ -123,7 +123,7 @@ public static Task install(DefaultDependencyManager dependenc ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.get().equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); - return new NeoForgeOldInstallTask(dependencyManager, version, modifyNeoForgeNewVersion(profile.getVersion()), installer); + return new NeoForgeOldInstallTask(dependencyManager, manifest, modifyNeoForgeNewVersion(profile.getVersion()), installer); } else { throw new IOException(); } From a5a7bf8ce9a6062ca8a9ad5d2c3efa5159c1fff2 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 14 Aug 2026 20:16:22 +0800 Subject: [PATCH 171/199] Refactor game installation to stage primary JARs through repository drafts Assisted-by: codex:gpt-5.6-sol --- .../hmcl/ui/instances/Instances.java | 10 +- .../download/DefaultDependencyManager.java | 15 +- .../hmcl/download/DefaultGameBuilder.java | 28 +-- .../cleanroom/CleanroomInstallTask.java | 98 +++++++-- .../hmcl/download/forge/ForgeInstallTask.java | 95 +++++---- .../download/forge/ForgeNewInstallTask.java | 24 ++- .../hmcl/download/game/GameDownloadTask.java | 106 +++++++-- .../hmcl/download/game/GameInstallTask.java | 5 +- .../neoforge/NeoForgeInstallTask.java | 49 ++++- .../neoforge/NeoForgeOldInstallTask.java | 24 ++- .../optifine/OptiFineInstallTask.java | 107 +++++++--- .../optifine/OptiFineRemoteVersion.java | 8 +- .../hmcl/game/DefaultGameRepository.java | 5 +- .../hmcl/game/DefaultGameRepositoryDraft.java | 201 ++++++++++++++---- .../hmcl/game/GameRepositoryDraft.java | 34 ++- .../multimc/MultiMCModpackInstallTask.java | 12 +- .../hmcl/game/DefaultGameInstanceTest.java | 23 +- .../game/DefaultGameRepositoryDraftTest.java | 51 +++++ 18 files changed, 688 insertions(+), 207 deletions(-) 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 d3997cd5649..7197411de52 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 @@ -191,18 +191,22 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { GameInstanceID instanceId = new GameInstanceID(result); DefaultDependencyManager dependencyManager = repository.getDependency(); + String gameVersion = manifest.id().id(); GameInstanceManifest newVersion = manifest.withId(instanceId).withJar(instanceId); + GameDownloadTask gameDownloadTask = new GameDownloadTask( + dependencyManager, + gameVersion, + newVersion); AtomicReference activeDraft = new AtomicReference<>(); Controllers.taskDialog( Task.supplyAsync(() -> { GameRepositoryDraft draft = repository.openDraft(); activeDraft.set(draft); - draft.put(newVersion); return draft; }) .thenComposeAsync(draft -> Task.allOf( - new GameDownloadTask(dependencyManager, null, newVersion), + gameDownloadTask, Task.allOf( new GameAssetDownloadTask( dependencyManager, @@ -218,6 +222,8 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { if (draft == null) { throw new IllegalStateException("Game repository draft is unavailable"); } + draft.put(newVersion); + draft.putPrimaryJar(instanceId, gameDownloadTask.getResult()); draft.commit(); }) .whenComplete(exception -> { 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 40d4405d990..c84f415b62a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -160,7 +160,11 @@ public Task checkPatchCompletionAsync( if (GameLibrariesTask.shouldDownloadLibrary(repository, manifest, installer, integrityCheck)) { tasks.add(installComponentAsync(instance, original, gameVersion, "optifine", optifinePatchVersion)); } else { - tasks.add(OptiFineInstallTask.install(this, original, repository.getLayout().getLibraryFile(manifest.id(), installer))); + tasks.add(OptiFineInstallTask.install( + this, + original, + gameVersion, + repository.getLayout().getLibraryFile(manifest.id(), installer))); } } } @@ -362,26 +366,27 @@ public Task installComponentAsync( if (!instance.getId().equals(baseManifest.id())) { throw new IllegalArgumentException("baseManifest id does not match instance"); } + String gameVersion = instance.getVersion().toString(); return Task .composeAsync(() -> { try { - return CleanroomInstallTask.install(this, baseManifest, installer); + return CleanroomInstallTask.install(this, baseManifest, gameVersion, installer); } catch (IOException ignore) { } try { - return NeoForgeInstallTask.install(this, baseManifest, installer); + return NeoForgeInstallTask.install(this, baseManifest, gameVersion, installer); } catch (IOException ignore) { } try { - return ForgeInstallTask.install(this, baseManifest, installer); + return ForgeInstallTask.install(this, baseManifest, gameVersion, installer); } catch (IOException ignore) { } try { - return OptiFineInstallTask.install(this, baseManifest, installer); + return OptiFineInstallTask.install(this, baseManifest, gameVersion, installer); } catch (IOException ignore) { } 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 4f7840edf25..956001dc91e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.game.DefaultGameRepository; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.game.GameInstanceManifest; @@ -33,8 +34,8 @@ /// Builds a new game instance in an exclusive [GameRepositoryDraft], installs its components, and /// publishes the completed instance once. /// -/// Shared libraries, assets, and download caches may remain after failure. The draft removes the -/// instance directory that it created and never publishes an incomplete manifest. +/// Shared libraries, assets, and download caches may remain after failure. The instance manifest +/// and primary JAR enter the instance tree only when the draft commits. @NotNullByDefault public class DefaultGameBuilder extends GameBuilder { @@ -87,9 +88,7 @@ public Task buildAsync() { return Task.supplyAsync(() -> { GameRepositoryDraft draft = repository.openDraft(); activeDraft.set(draft); - GameInstanceManifest manifest = new GameInstanceManifest(name); - draft.put(manifest); - return manifest; + return new GameInstanceManifest(name); }) .thenComposeAsync(initialManifest -> { Task libraryTask = Task.supplyAsync(() -> initialManifest); @@ -107,14 +106,17 @@ public Task buildAsync() { name, working, gameVersion, remoteVersion)); } - return libraryTask.thenApplyAsync(manifest -> { - GameRepositoryDraft draft = activeDraft.get(); - if (draft == null) { - throw new IllegalStateException("Game repository draft is unavailable"); - } - draft.put(manifest); - return draft.commit().getInstance(name); - }); + return libraryTask.thenComposeAsync(manifest -> + new GameDownloadTask(dependencyManager, gameVersion, manifest) + .thenApplyAsync(minecraftJar -> { + GameRepositoryDraft draft = activeDraft.get(); + if (draft == null) { + throw new IllegalStateException("Game repository draft is unavailable"); + } + draft.put(manifest); + draft.putPrimaryJar(name, minecraftJar); + return draft.commit().getInstance(name); + })); }) .whenComplete(exception -> { GameRepositoryDraft draft = activeDraft.getAndSet(null); 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 48ec0dbb17b..df442c13b8b 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 @@ -22,6 +22,7 @@ import org.jackhuang.hmcl.download.VersionMismatchException; import org.jackhuang.hmcl.download.forge.ForgeNewInstallProfile; import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask; +import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; @@ -29,6 +30,7 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.FileSystem; @@ -37,29 +39,53 @@ import java.util.Collection; import java.util.Collections; import java.util.Map; -import java.util.Optional; +import java.util.Objects; public final class CleanroomInstallTask extends Task { private final DefaultDependencyManager dependencyManager; private final GameInstanceManifest manifest; - private final CleanroomRemoteVersion remote; - private Path installer; - private FileDownloadTask dependent; - private Task task; - private String selfVersion; - - public CleanroomInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, CleanroomRemoteVersion remoteVersion) { + /// Minecraft version whose vanilla client JAR is required by the installer processors. + private final String gameVersion; + private final @Nullable CleanroomRemoteVersion remote; + private @Nullable Path installer; + private @Nullable FileDownloadTask dependent; + private @Nullable Task task; + private @Nullable String selfVersion; + + /// Creates a Cleanroom task that downloads the selected installer. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the Cleanroom patch + /// @param remoteVersion selected Cleanroom version + public CleanroomInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + CleanroomRemoteVersion remoteVersion) { this.dependencyManager = dependencyManager; this.manifest = manifest; + this.gameVersion = remoteVersion.getGameVersion(); this.remote = remoteVersion; setSignificance(TaskSignificance.MODERATE); } - public CleanroomInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, String selfVersion, Path installer) { + /// Creates a Cleanroom task backed by an existing local installer. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the Cleanroom patch + /// @param gameVersion Minecraft version expected by the installation + /// @param selfVersion Cleanroom version recorded in the returned patch + /// @param installer Cleanroom installer JAR + public CleanroomInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + String gameVersion, + String selfVersion, + Path installer) { this.dependencyManager = dependencyManager; this.manifest = manifest; + this.gameVersion = gameVersion; this.selfVersion = selfVersion; this.remote = null; this.installer = installer; @@ -77,8 +103,9 @@ public void preExecute() throws Exception { if (installer == null) { installer = Files.createTempFile("cleanroom-installer", ".jar"); + CleanroomRemoteVersion remoteVersion = Objects.requireNonNull(remote); dependent = new FileDownloadTask( - dependencyManager.getDownloadProvider().injectURLsWithCandidates(remote.getUrls()), + dependencyManager.getDownloadProvider().injectURLsWithCandidates(remoteVersion.getUrls()), installer, null); dependent.setCacheRepository(dependencyManager.getCacheRepository()); dependent.setCaching(true); @@ -94,10 +121,10 @@ public boolean doPostExecute() { @Override public void postExecute() throws Exception { if (remote != null) { - Files.deleteIfExists(installer); + Files.deleteIfExists(Objects.requireNonNull(installer)); } - setResult(task.getResult()); + setResult(Objects.requireNonNull(task).getResult()); } @Override @@ -107,31 +134,56 @@ public Collection> getDependents() { @Override public Collection> getDependencies() { - return Collections.singleton(task); + return Collections.singleton(Objects.requireNonNull(task)); } @Override public void execute() throws IOException, VersionMismatchException, UnsupportedInstallationException { + String cleanroomVersion; if (selfVersion == null) { - task = new ForgeNewInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer) - .thenApplyAsync((patch) -> patch.withId(GameComponentType.CLEANROOM)); + cleanroomVersion = Objects.requireNonNull(remote).getSelfVersion(); } else { - task = new ForgeNewInstallTask(dependencyManager, manifest, selfVersion, installer) - .thenApplyAsync((patch) -> patch.withId(GameComponentType.CLEANROOM)); + cleanroomVersion = selfVersion; } + + task = new GameDownloadTask(dependencyManager, gameVersion, manifest) + .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( + dependencyManager, + manifest, + minecraftJar, + cleanroomVersion, + Objects.requireNonNull(installer))) + .thenApplyAsync(patch -> patch.withId(GameComponentType.CLEANROOM)); } - public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(manifest); - if (gameVersion.isEmpty()) throw new IOException(); + /// Creates a task that installs Cleanroom from a local installer JAR. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the Cleanroom patch + /// @param gameVersion Minecraft version expected by the installation + /// @param installer Cleanroom installer JAR + /// @return the task producing the Cleanroom patch + /// @throws IOException if the installer profile is missing, malformed, or not a + /// Cleanroom profile + /// @throws VersionMismatchException if the installer targets another Minecraft version + public static Task install( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + String gameVersion, + Path installer) throws IOException, VersionMismatchException { try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); if (GameComponentType.CLEANROOM.getPatchId().equals(installProfile.get("profile"))) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); - if (!gameVersion.get().equals(profile.getMinecraft())) - throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); - return new CleanroomInstallTask(dependencyManager, manifest, modifyVersion(profile.getVersion()), installer); + if (!gameVersion.equals(profile.getMinecraft())) + throw new VersionMismatchException(profile.getMinecraft(), gameVersion); + return new CleanroomInstallTask( + dependencyManager, + manifest, + gameVersion, + modifyVersion(profile.getVersion()), + installer); } else { throw new IOException(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java index d7095012426..297ecd76368 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.download.forge; import org.jackhuang.hmcl.download.*; +import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; @@ -34,7 +35,6 @@ import java.util.Collection; import java.util.Collections; import java.util.Map; -import java.util.Optional; import static org.jackhuang.hmcl.download.UnsupportedInstallationException.UNSUPPORTED_LAUNCH_WRAPPER; import static org.jackhuang.hmcl.util.StringUtils.removePrefix; @@ -107,37 +107,40 @@ public void execute() throws IOException, VersionMismatchException, UnsupportedI throw new UnsupportedInstallationException(UNSUPPORTED_LAUNCH_WRAPPER); } - if (detectForgeInstallerType(dependencyManager, manifest, installer)) - dependency = new ForgeNewInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer); - else + if (detectForgeInstallerType(remote.getGameVersion(), installer)) { + dependency = new GameDownloadTask(dependencyManager, remote.getGameVersion(), manifest) + .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( + dependencyManager, + manifest, + minecraftJar, + remote.getSelfVersion(), + installer)); + } else { dependency = new ForgeOldInstallTask(dependencyManager, manifest, remote.getSelfVersion(), installer); + } } - /** - * Detect Forge installer type. - * - * @param dependencyManager game repository - * @param manifest instance manifest - * @param installer the Forge installer, either the new or old one. - * @return true for new, false for old - * @throws IOException if unable to read compressed content of installer file, or installer file is corrupted, or the installer is not the one we want. - * @throws VersionMismatchException if required game version of installer does not match the actual one. - */ - public static boolean detectForgeInstallerType(DependencyManager dependencyManager, GameInstanceManifest manifest, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(manifest); - if (gameVersion.isEmpty()) throw new IOException(); + /// Returns whether a Forge installer uses the processor-based format. + /// + /// @param gameVersion Minecraft version expected by the installation + /// @param installer the Forge installer JAR + /// @return `true` for the processor-based format, or `false` for the legacy format + /// @throws IOException if the installer profile is missing, malformed, or + /// unsupported + /// @throws VersionMismatchException if the installer targets another Minecraft version + public static boolean detectForgeInstallerType(String gameVersion, Path installer) throws IOException, VersionMismatchException { try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); if (installProfile.containsKey("spec")) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); - if (!gameVersion.get().equals(profile.getMinecraft())) - throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); + if (!gameVersion.equals(profile.getMinecraft())) + throw new VersionMismatchException(profile.getMinecraft(), gameVersion); return true; } else if (installProfile.containsKey("install") && installProfile.containsKey("versionInfo")) { ForgeInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeInstallProfile.class); - if (!gameVersion.get().equals(profile.getInstall().getMinecraft())) - throw new VersionMismatchException(profile.getInstall().getMinecraft(), gameVersion.get()); + if (!gameVersion.equals(profile.getInstall().getMinecraft())) + throw new VersionMismatchException(profile.getInstall().getMinecraft(), gameVersion); return false; } else { throw new IOException(); @@ -145,33 +148,43 @@ public static boolean detectForgeInstallerType(DependencyManager dependencyManag } } - /** - * Install Forge library from existing local file. - * This method will try to identify this installer whether it is in old or new format. - * - * @param dependencyManager game repository - * @param manifest instance manifest - * @param installer the Forge installer, either the new or old one. - * @return the task to install library - * @throws IOException if unable to read compressed content of installer file, or installer file is corrupted, or the installer is not the one we want. - * @throws VersionMismatchException if required game version of installer does not match the actual one. - */ - public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(manifest); - if (!gameVersion.isPresent()) throw new IOException(); + /// Creates a task that installs Forge from a local installer JAR. + /// + /// Processor-based installers obtain a verified vanilla client JAR from shared cache storage; + /// neither format reads an instance JAR. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the Forge patch + /// @param gameVersion Minecraft version expected by the installation + /// @param installer the Forge installer JAR + /// @return the task producing the Forge patch + /// @throws IOException if the installer profile is missing, malformed, or + /// unsupported + /// @throws VersionMismatchException if the installer targets another Minecraft version + public static Task install( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + String gameVersion, + Path installer) throws IOException, VersionMismatchException { try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); if (installProfile.containsKey("spec")) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); - if (!gameVersion.get().equals(profile.getMinecraft())) - throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); - return new ForgeNewInstallTask(dependencyManager, manifest, modifyVersion(gameVersion.get(), profile.getVersion()), installer); + if (!gameVersion.equals(profile.getMinecraft())) + throw new VersionMismatchException(profile.getMinecraft(), gameVersion); + return new GameDownloadTask(dependencyManager, gameVersion, manifest) + .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( + dependencyManager, + manifest, + minecraftJar, + modifyVersion(gameVersion, profile.getVersion()), + installer)); } else if (installProfile.containsKey("install") && installProfile.containsKey("versionInfo")) { ForgeInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeInstallProfile.class); - if (!gameVersion.get().equals(profile.getInstall().getMinecraft())) - throw new VersionMismatchException(profile.getInstall().getMinecraft(), gameVersion.get()); - return new ForgeOldInstallTask(dependencyManager, manifest, modifyVersion(gameVersion.get(), profile.getInstall().getPath().getVersion().replaceAll("(?i)forge", "")), installer); + if (!gameVersion.equals(profile.getInstall().getMinecraft())) + throw new VersionMismatchException(profile.getInstall().getMinecraft(), gameVersion); + return new ForgeOldInstallTask(dependencyManager, manifest, modifyVersion(gameVersion, profile.getInstall().getPath().getVersion().replaceAll("(?i)forge", "")), installer); } else { throw new IOException(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java index bce4af7d909..1f9afb95a2a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java @@ -189,6 +189,8 @@ public void execute() throws Exception { private final DefaultDependencyManager dependencyManager; private final DefaultGameRepository gameRepository; private final GameInstanceManifest manifest; + /// Vanilla client JAR used as processor input. + private final Path minecraftJar; private final Path installer; private final List> dependents = new ArrayList<>(1); private final List> dependencies = new ArrayList<>(1); @@ -201,10 +203,23 @@ public void execute() throws Exception { private Path tempDir; private final AtomicInteger processorDoneCount = new AtomicInteger(0); - public ForgeNewInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, String selfVersion, Path installer) { + /// Creates a Forge processor installation task. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the Forge patch + /// @param minecraftJar vanilla client JAR for the target Minecraft version + /// @param selfVersion Forge version recorded in the returned patch + /// @param installer Forge installer JAR + public ForgeNewInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + Path minecraftJar, + String selfVersion, + Path installer) { this.dependencyManager = dependencyManager; this.gameRepository = dependencyManager.getGameRepository(); this.manifest = manifest; + this.minecraftJar = minecraftJar; this.installer = installer; this.selfVersion = selfVersion; @@ -380,6 +395,9 @@ private Task createProcessorTask(Processor processor, Map var @Override public void execute() throws Exception { + if (!Files.isRegularFile(minecraftJar)) { + throw new FileNotFoundException("Minecraft client JAR not found: " + minecraftJar); + } tempDir = Files.createTempDirectory("forge_installer"); Map vars = new HashMap<>(); @@ -402,8 +420,8 @@ public void execute() throws Exception { } vars.put("SIDE", "client"); - vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(gameRepository.getInstanceJar(manifest))); - vars.put("MINECRAFT_VERSION", FileUtils.getAbsolutePath(gameRepository.getInstanceJar(manifest))); + vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(minecraftJar)); + vars.put("MINECRAFT_VERSION", FileUtils.getAbsolutePath(minecraftJar)); vars.put("ROOT", FileUtils.getAbsolutePath(gameRepository.getBaseDirectory())); vars.put("INSTALLER", installer.toAbsolutePath().toString()); vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLayout().getLibrariesDirectory())); 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 f6427318a84..0af0974fe53 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.download.game; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.game.DownloadInfo; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; @@ -25,43 +26,45 @@ import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Collection; import java.util.List; -/// Downloads a Minecraft client jar to a repository-resolved or explicitly fixed destination. +/// Downloads a Minecraft client JAR to shared cache storage or an explicitly fixed destination. @NotNullByDefault -public final class GameDownloadTask extends Task { +public final class GameDownloadTask extends Task { /// The dependency manager supplying downloads and cache access. private final DefaultDependencyManager dependencyManager; - /// The optional Minecraft version used to locate a cached jar candidate. - private final @Nullable String gameVersion; - /// The resolved manifest that supplies client download metadata. private final GameInstanceManifest manifest; - /// The explicit destination fixed when this task is created, or `null` to resolve it at execution. - private final @Nullable Path jar; + /// Destination fixed when this task is created. + private final Path jar; + + /// Optional pre-existing file that may seed an explicit destination. + private final @Nullable Path candidate; /// The file-download task created during execution. private final List> dependencies = new ArrayList<>(); - /// Creates a task whose destination is resolved from the repository when execution starts. + /// Creates a task that downloads a versioned client JAR into shared cache storage. /// /// @param dependencyManager the dependency manager used for resolution and downloading - /// @param gameVersion the Minecraft version used as a cache key, or `null` + /// @param gameVersion the Minecraft version used as the shared-cache key /// @param manifest the manifest supplying client download metadata public GameDownloadTask( DefaultDependencyManager dependencyManager, - @Nullable String gameVersion, + String gameVersion, GameInstanceManifest manifest) { this.dependencyManager = dependencyManager; - this.gameVersion = gameVersion; this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); - this.jar = null; + this.jar = getSharedJarPath(dependencyManager, gameVersion); + this.candidate = null; setSignificance(TaskSignificance.MODERATE); } @@ -78,13 +81,48 @@ public GameDownloadTask( GameInstanceManifest manifest, Path jar) { this.dependencyManager = dependencyManager; - this.gameVersion = gameVersion; this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); this.jar = jar; + @Nullable Path sharedJar = gameVersion != null ? getSharedJarPath(dependencyManager, gameVersion) : null; + this.candidate = sharedJar != null && !sameNormalizedPath(sharedJar, jar) ? sharedJar : null; setSignificance(TaskSignificance.MODERATE); } + /// Returns the shared client-JAR path for a Minecraft version. + /// + /// @param dependencyManager the dependency manager owning the shared cache + /// @param gameVersion the Minecraft version used as the file name + /// @return the normalized path below the cache's `jars` directory + /// @throws IllegalArgumentException if the version is blank or would escape the `jars` + /// directory + private static Path getSharedJarPath( + DefaultDependencyManager dependencyManager, + String gameVersion) { + if (gameVersion.isBlank()) { + throw new IllegalArgumentException("Minecraft version must not be blank"); + } + Path directory = dependencyManager.getCacheRepository() + .getCommonDirectory() + .resolve("jars") + .toAbsolutePath() + .normalize(); + Path destination = directory.resolve(gameVersion + ".jar").normalize(); + if (!directory.equals(destination.getParent())) { + throw new IllegalArgumentException("Invalid Minecraft version for cache path: " + gameVersion); + } + return destination; + } + + /// Returns whether two paths identify the same normalized absolute path. + /// + /// @param first the first path + /// @param second the second path + /// @return whether the normalized paths are equal + private static boolean sameNormalizedPath(Path first, Path second) { + return first.toAbsolutePath().normalize().equals(second.toAbsolutePath().normalize()); + } + /// Returns the download created by [#execute()], if execution has started. /// /// @return the live dependency collection @@ -93,22 +131,44 @@ public Collection> getDependencies() { return dependencies; } - /// Creates the file-download dependency for the configured destination. + /// Creates the file-download dependency unless the destination already has the expected content. @Override - public void execute() { - Path destination = jar != null - ? jar - : dependencyManager.getGameRepository().getInstanceJar(manifest); + public void execute() throws IOException { + DownloadInfo downloadInfo = manifest.getDownloadInfo(); + if (Files.isRegularFile(jar) && downloadInfo.validateChecksum(jar, false)) { + return; + } + var task = new FileDownloadTask( - dependencyManager.getDownloadProvider().injectURLWithCandidates(manifest.getDownloadInfo().getUrl()), - destination, - FileDownloadTask.IntegrityCheck.of(CacheRepository.SHA1, manifest.getDownloadInfo().getSha1())); + dependencyManager.getDownloadProvider().injectURLWithCandidates(downloadInfo.getUrl()), + jar, + FileDownloadTask.IntegrityCheck.of(CacheRepository.SHA1, downloadInfo.getSha1())); task.setCaching(true); task.setCacheRepository(dependencyManager.getCacheRepository()); - if (gameVersion != null) - task.setCandidate(dependencyManager.getCacheRepository().getCommonDirectory().resolve("jars").resolve(gameVersion + ".jar")); + if (candidate != null) { + task.setCandidate(candidate); + } dependencies.add(task); } + + /// Requests post-execution so the completed destination can be returned. + /// + /// @return `true` + @Override + public boolean doPostExecute() { + return true; + } + + /// Returns the downloaded or previously validated client JAR. + /// + /// @throws IOException if the destination was not materialized + @Override + public void postExecute() throws IOException { + if (!Files.isRegularFile(jar)) { + throw new IOException("Minecraft client JAR was not downloaded: " + jar); + } + setResult(jar); + } } 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 a7bea9e7fb6..0fbd1ee3760 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 @@ -32,8 +32,9 @@ /// Downloads the base game component and returns its manifest patch without publishing it. /// -/// Game files, libraries, and assets are downloaded as dependencies. The caller owns the working -/// manifest and must stage the returned patch in its repository draft. +/// The vanilla client JAR is downloaded into shared cache storage; libraries and assets use their +/// repository-wide stores. The caller owns the working manifest and must stage the returned patch +/// in its repository draft. @NotNullByDefault public class GameInstallTask extends Task { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java index 3d82f8efc42..0c368bb1f2d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.VersionMismatchException; import org.jackhuang.hmcl.download.forge.*; +import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; @@ -97,20 +98,42 @@ public Collection> getDependencies() { @Override public void execute() throws Exception { - dependency = install(dependencyManager, manifest, installer); + dependency = install(dependencyManager, manifest, remoteVersion.getGameVersion(), installer); } - public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(manifest); - if (gameVersion.isEmpty()) throw new IOException(); + /// Creates a task that installs NeoForge from a local installer JAR. + /// + /// The returned task obtains the matching vanilla client JAR from shared cache storage and + /// passes it explicitly to the selected processor implementation. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the NeoForge patch + /// @param gameVersion Minecraft version expected by the installation + /// @param installer the NeoForge installer JAR + /// @return the task producing the NeoForge patch + /// @throws IOException if the installer profile is missing, malformed, or + /// unsupported + /// @throws VersionMismatchException if the installer targets another Minecraft version + public static Task install( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + String gameVersion, + Path installer) throws IOException, VersionMismatchException { try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { String installProfileText = Files.readString(fs.getPath("install_profile.json")); Map installProfile = JsonUtils.fromNonNullJson(installProfileText, Map.class); if (GameComponentType.FORGE.getPatchId().equals(installProfile.get("profile")) && (Files.exists(fs.getPath("META-INF/NEOFORGE.RSA")) || installProfileText.contains("neoforge"))) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); - if (!gameVersion.get().equals(profile.getMinecraft())) - throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); - return new ForgeNewInstallTask(dependencyManager, manifest, modifyNeoForgeOldVersion(gameVersion.get(), profile.getVersion()), installer).thenApplyAsync(neoForgeVersion -> { + if (!gameVersion.equals(profile.getMinecraft())) + throw new VersionMismatchException(profile.getMinecraft(), gameVersion); + return new GameDownloadTask(dependencyManager, gameVersion, manifest) + .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( + dependencyManager, + manifest, + minecraftJar, + modifyNeoForgeOldVersion(gameVersion, profile.getVersion()), + installer)) + .thenApplyAsync(neoForgeVersion -> { if (!neoForgeVersion.id().equals(GameComponentType.FORGE.getPatchId()) || neoForgeVersion.version() == null) { throw new IOException("Invalid neoforge version."); } @@ -121,9 +144,15 @@ public static Task install(DefaultDependencyManager dependenc }); } else if (GameComponentType.NEO_FORGE.getPatchId().equals(installProfile.get("profile")) || "NeoForge".equals(installProfile.get("profile"))) { ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); - if (!gameVersion.get().equals(profile.getMinecraft())) - throw new VersionMismatchException(profile.getMinecraft(), gameVersion.get()); - return new NeoForgeOldInstallTask(dependencyManager, manifest, modifyNeoForgeNewVersion(profile.getVersion()), installer); + if (!gameVersion.equals(profile.getMinecraft())) + throw new VersionMismatchException(profile.getMinecraft(), gameVersion); + return new GameDownloadTask(dependencyManager, gameVersion, manifest) + .thenComposeAsync(minecraftJar -> new NeoForgeOldInstallTask( + dependencyManager, + manifest, + minecraftJar, + modifyNeoForgeNewVersion(profile.getVersion()), + installer)); } else { throw new IOException(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java index 3e30f87b84c..aa30e1a7053 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java @@ -173,6 +173,8 @@ public void execute() throws Exception { private final DefaultDependencyManager dependencyManager; private final DefaultGameRepository gameRepository; private final GameInstanceManifest manifest; + /// Vanilla client JAR used as processor input. + private final Path minecraftJar; private final Path installer; private final List> dependents = new ArrayList<>(1); private final List> dependencies = new ArrayList<>(1); @@ -185,10 +187,23 @@ public void execute() throws Exception { private Path tempDir; private AtomicInteger processorDoneCount = new AtomicInteger(0); - NeoForgeOldInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, String selfVersion, Path installer) { + /// Creates a legacy NeoForge processor installation task. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the NeoForge patch + /// @param minecraftJar vanilla client JAR for the target Minecraft version + /// @param selfVersion NeoForge version recorded in the returned patch + /// @param installer NeoForge installer JAR + NeoForgeOldInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + Path minecraftJar, + String selfVersion, + Path installer) { this.dependencyManager = dependencyManager; this.gameRepository = dependencyManager.getGameRepository(); this.manifest = manifest; + this.minecraftJar = minecraftJar; this.installer = installer; this.selfVersion = selfVersion; @@ -364,6 +379,9 @@ private Task createProcessorTask(Processor processor, Map var @Override public void execute() throws Exception { + if (!Files.isRegularFile(minecraftJar)) { + throw new FileNotFoundException("Minecraft client JAR not found: " + minecraftJar); + } tempDir = Files.createTempDirectory("neoforge_installer"); Map vars = new HashMap<>(); @@ -386,8 +404,8 @@ public void execute() throws Exception { } vars.put("SIDE", "client"); - vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(gameRepository.getInstanceJar(manifest))); - vars.put("MINECRAFT_VERSION", FileUtils.getAbsolutePath(gameRepository.getInstanceJar(manifest))); + vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(minecraftJar)); + vars.put("MINECRAFT_VERSION", FileUtils.getAbsolutePath(minecraftJar)); vars.put("ROOT", FileUtils.getAbsolutePath(gameRepository.getBaseDirectory())); vars.put("INSTALLER", installer.toAbsolutePath().toString()); vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLayout().getLibrariesDirectory())); 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 4de1ca45889..fd93136d3f9 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 @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.UnsupportedInstallationException; import org.jackhuang.hmcl.download.VersionMismatchException; +import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; @@ -33,6 +34,7 @@ import org.jenkinsci.constant_pool_scanner.ConstantPoolScanner; import org.jenkinsci.constant_pool_scanner.ConstantType; import org.jenkinsci.constant_pool_scanner.Utf8Constant; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.FileSystem; @@ -53,23 +55,48 @@ public final class OptiFineInstallTask extends Task { private final DefaultDependencyManager dependencyManager; private final GameInstanceManifest manifest; private final OptiFineRemoteVersion remote; - private final Path installer; + /// Vanilla client JAR used as patcher input. + private final Path minecraftJar; + private final @Nullable Path installer; private final List> dependents = new ArrayList<>(0); private final List> dependencies = new ArrayList<>(1); - private Path dest; + private @Nullable Path dest; private final Library optiFineLibrary; private final Library optiFineInstallerLibrary; - public OptiFineInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, OptiFineRemoteVersion remoteVersion) { - this(dependencyManager, manifest, remoteVersion, null); + /// Creates an OptiFine task that downloads its installer. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the OptiFine patch + /// @param remoteVersion selected OptiFine version + /// @param minecraftJar vanilla client JAR for the target Minecraft version + public OptiFineInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + OptiFineRemoteVersion remoteVersion, + Path minecraftJar) { + this(dependencyManager, manifest, remoteVersion, minecraftJar, null); } - public OptiFineInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest manifest, OptiFineRemoteVersion remoteVersion, Path installer) { + /// Creates an OptiFine task with an optional local installer. + /// + /// @param dependencyManager repository-scoped download services + /// @param manifest working manifest receiving the OptiFine patch + /// @param remoteVersion selected OptiFine version + /// @param minecraftJar vanilla client JAR for the target Minecraft version + /// @param installer local installer JAR, or `null` to download it + public OptiFineInstallTask( + DefaultDependencyManager dependencyManager, + GameInstanceManifest manifest, + OptiFineRemoteVersion remoteVersion, + Path minecraftJar, + @Nullable Path installer) { this.dependencyManager = dependencyManager; this.gameRepository = dependencyManager.getGameRepository(); this.manifest = manifest; this.remote = remoteVersion; + this.minecraftJar = minecraftJar; this.installer = installer; String mavenVersion = remote.getGameVersion() + "_" + remote.getSelfVersion(); @@ -91,17 +118,18 @@ public boolean doPreExecute() { @Override public void preExecute() throws Exception { - dest = Files.createTempFile("optifine-installer", ".jar"); + Path installerFile = Files.createTempFile("optifine-installer", ".jar"); + dest = installerFile; if (installer == null) { var task = new FileDownloadTask( dependencyManager.getDownloadProvider().injectURLsWithCandidates(remote.getUrls()), - dest, null); + installerFile, null); task.setCacheRepository(dependencyManager.getCacheRepository()); task.setCaching(true); dependents.add(task); } else { - FileUtils.copyFile(installer, dest); + FileUtils.copyFile(installer, installerFile); } } @@ -122,6 +150,10 @@ public boolean isRelyingOnDependencies() { @Override public void execute() throws Exception { + if (!Files.isRegularFile(minecraftJar)) { + throw new IOException("Minecraft client JAR not found: " + minecraftJar); + } + Path installerFile = Objects.requireNonNull(dest); String originalMainClass = dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass(); if (!GameComponentAnalyzer.FORGE_OPTIFINE_MAIN.contains(originalMainClass)) throw new UnsupportedInstallationException(UnsupportedInstallationException.UNSUPPORTED_LAUNCH_WRAPPER); @@ -130,7 +162,7 @@ public void execute() throws Exception { libraries.add(optiFineLibrary); Path optiFineInstallerLibraryPath = gameRepository.getLayout().getLibraryFile(manifest.id(), optiFineInstallerLibrary); - FileUtils.copyFile(dest, optiFineInstallerLibraryPath); + FileUtils.copyFile(installerFile, optiFineInstallerLibraryPath); try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(optiFineInstallerLibraryPath)) { Files.deleteIfExists(fs2.getPath("/META-INF/mods.toml")); @@ -138,23 +170,23 @@ public void execute() throws Exception { // Install launch wrapper modified by OptiFine boolean hasLaunchWrapper = false; - try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(dest)) { + try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installerFile)) { Path optiFineLibraryPath = gameRepository.getLayout().getLibraryFile(manifest.id(), optiFineLibrary); if (Files.exists(fs.getPath("optifine/Patcher.class"))) { String[] command = { JavaRuntime.getDefault().getBinary().toString(), "-cp", - dest.toString(), + installerFile.toString(), "optifine.Patcher", - gameRepository.getInstanceJar(manifest).toAbsolutePath().normalize().toString(), - dest.toString(), + minecraftJar.toAbsolutePath().normalize().toString(), + installerFile.toString(), optiFineLibraryPath.toString() }; int exitCode = SystemUtils.callExternalProcess(command); if (exitCode != 0) throw new IOException("OptiFine patcher failed, command: " + new CommandBuilder().addAll(Arrays.asList(command))); } else { - FileUtils.copyFile(dest, optiFineLibraryPath); + FileUtils.copyFile(installerFile, optiFineLibraryPath); } try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(optiFineLibraryPath)) { @@ -218,19 +250,20 @@ public void execute() throws Exception { dependencies.add(new org.jackhuang.hmcl.download.game.GameLibrariesTask(dependencyManager, manifest, true, getResult().getLibraries())); } - /** - * Install OptiFine library from existing local file. - * - * @param dependencyManager game repository - * @param version instance manifest - * @param installer the OptiFine installer - * @return the task to install library - * @throws IOException if unable to read compressed content of installer file, or installer file is corrupted, or the installer is not the one we want. - * @throws VersionMismatchException if required game version of installer does not match the actual one. - */ - public static Task install(DefaultDependencyManager dependencyManager, GameInstanceManifest version, Path installer) throws IOException, VersionMismatchException { - Optional gameVersion = dependencyManager.getGameRepository().getGameVersion(version); - if (!gameVersion.isPresent()) throw new IOException(); + /// Creates a task that installs OptiFine from a local installer JAR. + /// + /// @param dependencyManager repository-scoped download services + /// @param version working manifest receiving the OptiFine patch + /// @param gameVersion Minecraft version expected by the installation + /// @param installer the OptiFine installer JAR + /// @return the task producing the OptiFine patch + /// @throws IOException if the installer is malformed or unsupported + /// @throws VersionMismatchException if the installer targets another Minecraft version + public static Task install( + DefaultDependencyManager dependencyManager, + GameInstanceManifest version, + String gameVersion, + Path installer) throws IOException, VersionMismatchException { try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(installer)) { Path configClass = fs.getPath("Config.class"); if (!Files.exists(configClass)) configClass = fs.getPath("net/optifine/Config.class"); @@ -246,11 +279,21 @@ public static Task install(DefaultDependencyManager dependenc if (mcVersion == null || ofEdition == null || ofRelease == null) throw new IOException("Unrecognized OptiFine installer"); - if (!mcVersion.equals(gameVersion.get())) - throw new VersionMismatchException(mcVersion, gameVersion.get()); - - return new OptiFineInstallTask(dependencyManager, version, - new OptiFineRemoteVersion(mcVersion, ofEdition + "_" + ofRelease, Collections.singletonList(""), false), installer); + if (!mcVersion.equals(gameVersion)) + throw new VersionMismatchException(mcVersion, gameVersion); + + OptiFineRemoteVersion remoteVersion = new OptiFineRemoteVersion( + mcVersion, + ofEdition + "_" + ofRelease, + Collections.singletonList(""), + false); + return new GameDownloadTask(dependencyManager, gameVersion, version) + .thenComposeAsync(minecraftJar -> new OptiFineInstallTask( + dependencyManager, + version, + remoteVersion, + minecraftJar, + installer)); } } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java index 6e527984393..deb3a0d6b5e 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 @@ -19,6 +19,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.RemoteVersion; +import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameInstancePatch; @@ -39,6 +40,11 @@ public String getFullVersion() { @Override public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new OptiFineInstallTask(dependencyManager, baseVersion, this); + return new GameDownloadTask(dependencyManager, getGameVersion(), baseVersion) + .thenComposeAsync(minecraftJar -> new OptiFineInstallTask( + dependencyManager, + baseVersion, + this, + minecraftJar)); } } 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 03dc2c4fd17..27ea2690d59 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -131,8 +131,9 @@ protected boolean mayClaimDraftInstanceRoot(GameInstanceID instanceId, Path inst /// Materializes subclass-specific data for a newly claimed draft instance root. /// - /// This method is called after the draft has recorded ownership, so failure cleanup will remove - /// the root. The default implementation has no additional data to materialize. + /// 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 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 c5c2d6e8b38..280533c703e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -30,6 +30,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Set; import java.util.TreeMap; @@ -40,9 +41,9 @@ /// Default exclusive [GameRepositoryDraft] implementation. /// /// Manifest changes are retained in memory until commit. A successful commit writes the final -/// manifests, applies removals and renames, and publishes one new immutable snapshot. Shared library -/// and asset cache writes are outside the rollback boundary. Instances of this class are not -/// thread-safe. +/// manifests and primary JARs, applies removals and renames, and publishes one new immutable +/// snapshot. Missing new-instance roots are created while committing. Shared library and asset +/// cache writes are outside the rollback boundary. Instances of this class are not thread-safe. @NotNullByDefault public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { @@ -58,6 +59,9 @@ public final class DefaultGameRepositoryDraft implements GameRepositoryDraft { /// Instance ids whose final manifests differ from the published snapshot. private final Set modifiedIds = new TreeSet<>(); + /// Completed primary JAR sources to copy into instance roots during commit. + private final Map primaryJarSources = new TreeMap<>(); + /// Instance ids whose root directories were absent before this draft first added them. private final Set createdIds = new TreeSet<>(); @@ -112,6 +116,27 @@ public void put(GameInstanceManifest manifest) throws IOException { putManifest(manifest, true); } + /// {@inheritDoc} + @Override + public void putPrimaryJar(GameInstanceID instanceId, Path source) throws IOException { + checkOpen(); + if (!manifests.containsKey(instanceId)) { + throw new NoSuchGameInstanceException(instanceId); + } + + Path normalizedSource = source.toAbsolutePath().normalize(); + if (!Files.isRegularFile(normalizedSource)) { + throw new IOException("Primary JAR source is not a regular file: " + normalizedSource); + } + + Path target = getPrimaryJarTarget(instanceId); + if (normalizedSource.equals(target)) { + primaryJarSources.remove(instanceId); + } else { + primaryJarSources.put(instanceId, normalizedSource); + } + } + /// {@inheritDoc} @Override public void remove(GameInstanceID instanceId) { @@ -121,6 +146,7 @@ public void remove(GameInstanceID instanceId) { } modifiedIds.remove(instanceId); + primaryJarSources.remove(instanceId); removedIds.add(instanceId); } @@ -155,6 +181,11 @@ public void rename(GameInstanceID from, GameInstanceID to) throws IOException { removedIds.remove(from); putManifest(renamedManifest, false); + @Nullable Path primaryJarSource = primaryJarSources.remove(from); + if (primaryJarSource != null) { + primaryJarSources.put(to, primaryJarSource); + } + manifests.replaceAll((id, manifest) -> { if (!from.equals(manifest.inheritsFrom())) { return manifest; @@ -169,23 +200,26 @@ public void rename(GameInstanceID from, GameInstanceID to) throws IOException { /// /// @param manifest the manifest to retain /// @param claimNewRoot whether a previously absent instance root should become draft-owned - /// @throws IOException if a new instance root cannot be claimed or initialized + /// @throws IOException if a new instance root cannot be reserved private void putManifest( GameInstanceManifest manifest, boolean claimNewRoot) throws IOException { GameInstanceID id = manifest.id(); - if (claimNewRoot && !manifests.containsKey(id)) { + if (claimNewRoot + && !manifests.containsKey(id) + && baseSnapshot.get(id) == null + && !createdIds.contains(id)) { Path root = getValidatedInstanceRoot(id); if (!repository.mayClaimDraftInstanceRoot(id, root)) { throw new FileAlreadyExistsException(root.toString(), null, "An unregistered instance directory already exists"); } createdIds.add(id); - repository.initializeDraftInstanceRoot(id, root); } manifests.put(id, manifest); + removedIds.remove(id); modifiedIds.add(id); } @@ -198,26 +232,34 @@ public DefaultGameRepositorySnapshot commit() throws IOException { List appliedRenames = new ArrayList<>(); List removedRoots = new ArrayList<>(); - List applied = new ArrayList<>(); + List appliedFiles = new ArrayList<>(); @Nullable Path rollbackDirectory = null; try { DefaultGameRepositorySnapshot committedSnapshot = buildCommittedSnapshot(); for (RenameOperation rename : renames) { applyRename(rename, appliedRenames); } + materializeCreatedInstanceRoots(); - if (!removedIds.isEmpty() || !modifiedIds.isEmpty()) { + if (!removedIds.isEmpty() || !modifiedIds.isEmpty() || !primaryJarSources.isEmpty()) { Path currentRollbackDirectory = createRollbackDirectory(); rollbackDirectory = currentRollbackDirectory; for (GameInstanceID id : removedIds) { removeInstanceRoot(id, currentRollbackDirectory, removedRoots); } + for (Map.Entry entry : primaryJarSources.entrySet()) { + applyPrimaryJar( + entry.getKey(), + entry.getValue(), + currentRollbackDirectory, + appliedFiles); + } for (GameInstanceID id : modifiedIds) { @Nullable GameInstanceManifest manifest = manifests.get(id); if (manifest == null) { throw new IllegalStateException("Modified manifest is missing: " + id); } - applyManifest(id, manifest, currentRollbackDirectory, applied); + applyManifest(id, manifest, currentRollbackDirectory, appliedFiles); } } @@ -227,7 +269,7 @@ public DefaultGameRepositorySnapshot commit() throws IOException { cleanupRollbackDirectoryAfterCommit(rollbackDirectory); return committedSnapshot; } catch (IOException | RuntimeException e) { - IOException rollbackFailure = rollbackAppliedManifests(applied); + IOException rollbackFailure = rollbackAppliedFiles(appliedFiles); rollbackFailure = accumulateNullable(rollbackFailure, rollbackRemovedRoots(removedRoots)); rollbackFailure = accumulateNullable(rollbackFailure, rollbackRenames(appliedRenames)); state = GameRepositoryDraft.State.FAILED; @@ -270,6 +312,20 @@ private DefaultGameRepositorySnapshot buildCommittedSnapshot() { return committedSnapshot; } + /// Creates and initializes roots reserved for instances added by this draft. + /// + /// @throws IOException if a root or repository-specific initial data cannot be created + private void materializeCreatedInstanceRoots() throws IOException { + for (GameInstanceID id : createdIds) { + if (!manifests.containsKey(id)) { + continue; + } + Path root = getValidatedInstanceRoot(id); + Files.createDirectories(root); + repository.initializeDraftInstanceRoot(id, root); + } + } + /// {@inheritDoc} @Override public void abort() throws IOException { @@ -316,6 +372,41 @@ private Path getManifestTarget(GameInstanceID id) { .normalize(); } + /// Returns and validates the permanent target for an instance's own primary JAR. + /// + /// Existing instances retain a non-conventional JAR path discovered during refresh. New and + /// renamed instances use the conventional path from the repository layout. + /// + /// @param id the instance id + /// @return the normalized primary JAR target + /// @throws IOException if the target escapes the instance root + private Path getPrimaryJarTarget(GameInstanceID id) throws IOException { + @Nullable DefaultGameInstance existing = baseSnapshot.get(id); + Path target = (existing != null + ? existing.getOwnJarFile() + : baseSnapshot.getLayout().getInstanceJarFile(id)) + .toAbsolutePath() + .normalize(); + validateInstanceFileTarget(id, target, "Primary JAR"); + return target; + } + + /// Verifies that a file target is a strict descendant of its instance root. + /// + /// @param id the owning instance id + /// @param target the normalized target path + /// @param description description used in an exception message + /// @throws IOException if the target is outside the instance root + private void validateInstanceFileTarget( + GameInstanceID id, + Path target, + String description) throws IOException { + Path expectedRoot = getValidatedInstanceRoot(id); + if (target.equals(expectedRoot) || !target.startsWith(expectedRoot)) { + throw new IOException(description + " path escapes instance root: " + target); + } + } + /// Creates a directory for rollback data produced by the current commit attempt. /// /// @return the new rollback directory @@ -386,41 +477,79 @@ private void applyManifest( GameInstanceID id, GameInstanceManifest manifest, Path rollbackDirectory, - List applied) throws IOException { + List applied) throws IOException { String json = JsonUtils.GSON.toJson(manifest); Path target = getManifestTarget(id); - Path expectedRoot = getValidatedInstanceRoot(id); - if (target.equals(expectedRoot) || !target.startsWith(expectedRoot)) { - throw new IOException("Manifest path escapes instance root: " + target); + validateInstanceFileTarget(id, target, "Manifest"); + + Files.createDirectories(target.getParent()); + @Nullable Path backup = backupFile(target, rollbackDirectory, "manifest-", ".json"); + applied.add(new AppliedFile(target, backup)); + Files.writeString(target, json); + } + + /// Copies a completed primary JAR into its permanent instance location while retaining a + /// rollback copy of an existing target. + /// + /// @param id the instance receiving the JAR + /// @param source the completed source JAR + /// @param rollbackDirectory the directory holding rollback files + /// @param applied rollback records for files already changed + /// @throws IOException if the source or target cannot be read or written + private void applyPrimaryJar( + GameInstanceID id, + Path source, + Path rollbackDirectory, + List applied) throws IOException { + if (!Files.isRegularFile(source)) { + throw new IOException("Primary JAR source is not a regular file: " + source); } + Path target = getPrimaryJarTarget(id); Files.createDirectories(target.getParent()); - @Nullable Path backup = null; - if (Files.exists(target)) { - Path backups = rollbackDirectory.resolve("backups"); - Files.createDirectories(backups); - backup = Files.createTempFile(backups, "manifest-", ".json"); - Files.delete(backup); - moveReplacing(target, backup); + @Nullable Path backup = backupFile(target, rollbackDirectory, "jar-", ".jar"); + applied.add(new AppliedFile(target, backup)); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + } + + /// Moves an existing target into rollback storage. + /// + /// @param target the file about to be replaced + /// @param rollbackDirectory the directory holding rollback files + /// @param prefix the backup file prefix + /// @param suffix the backup file suffix + /// @return the backup path, or `null` when the target did not exist + /// @throws IOException if the target cannot be backed up + private static @Nullable Path backupFile( + Path target, + Path rollbackDirectory, + String prefix, + String suffix) throws IOException { + if (Files.notExists(target)) { + return null; } - applied.add(new AppliedManifest(target, backup)); - Files.writeString(target, json); + Path backups = rollbackDirectory.resolve("backups"); + Files.createDirectories(backups); + Path backup = Files.createTempFile(backups, prefix, suffix); + Files.delete(backup); + moveReplacing(target, backup); + return backup; } - /// Restores manifests changed by an unsuccessful commit in reverse application order. + /// Restores files changed by an unsuccessful commit in reverse application order. /// - /// @param applied applied manifest records + /// @param applied applied file records /// @return the aggregated rollback failure, or `null` when rollback succeeded - private static @Nullable IOException rollbackAppliedManifests(List applied) { + private static @Nullable IOException rollbackAppliedFiles(List applied) { @Nullable IOException failure = null; - List reversed = new ArrayList<>(applied); + List reversed = new ArrayList<>(applied); Collections.reverse(reversed); - for (AppliedManifest manifest : reversed) { + for (AppliedFile file : reversed) { try { - Files.deleteIfExists(manifest.targetFile()); - if (manifest.backupFile() != null) { - moveReplacing(manifest.backupFile(), manifest.targetFile()); + Files.deleteIfExists(file.targetFile()); + if (file.backupFile() != null) { + moveReplacing(file.backupFile(), file.targetFile()); } } catch (IOException e) { failure = accumulate(failure, e); @@ -587,15 +716,15 @@ private static IOException accumulate(@Nullable IOException current, IOException /// @throws IllegalStateException if the draft is not open private void checkOpen() { if (state != GameRepositoryDraft.State.OPEN) { - throw new IllegalStateException("Draft is " + state.name().toLowerCase()); + throw new IllegalStateException("Draft is " + state.name().toLowerCase(Locale.ROOT)); } } - /// Records enough information to roll back one manifest replacement. + /// Records enough information to roll back one file replacement. /// - /// @param targetFile the permanent manifest path - /// @param backupFile the prior manifest backup, or `null` when no prior file existed - private record AppliedManifest( + /// @param targetFile the permanent file path + /// @param backupFile the prior file backup, or `null` when no prior file existed + private record AppliedFile( Path targetFile, @Nullable Path backupFile) { } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java index 9967a571208..6093e13c386 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepositoryDraft.java @@ -20,6 +20,7 @@ import org.jetbrains.annotations.NotNullByDefault; import java.io.IOException; +import java.nio.file.Path; /// Provides the exclusive write session for a game repository. /// @@ -29,8 +30,9 @@ /// during commit. /// /// A repository permits at most one open draft. Repository refreshes, layout changes, and other -/// writes are rejected while the draft is open. Aborting a draft removes instance directories that -/// were first created by that draft. Shared library, asset, and download caches are not reverted. +/// writes are rejected while the draft is open. Draft mutation methods do not materialize new +/// instance directories; [#commit()] creates missing roots as needed. Shared library, asset, and +/// download caches are not reverted. /// Drafts are not thread-safe; callers must serialize all operations on a draft. /// /// @see GameRepository#openDraft() @@ -79,14 +81,28 @@ enum State { /// Adds or replaces a stored manifest in the unpublished draft state. /// /// The manifest is retained in memory until commit. This operation does not serialize the - /// manifest or create or expose a [GameInstance]. For a new id, the draft claims its instance - /// root and may initialize repository-specific files there. + /// manifest or create or expose a [GameInstance]. For a new id, the draft reserves its instance + /// root but does not create it before commit. /// /// @param manifest the persistent instance manifest - /// @throws IOException if a new instance root cannot be claimed or initialized + /// @throws IOException if a new instance root cannot be reserved /// @throws IllegalStateException if the draft is not open void put(GameInstanceManifest manifest) throws IOException; + /// Records a primary client JAR to copy into an instance when this draft commits. + /// + /// The source remains outside the instance tree and is not modified by this draft. It must stay + /// available and unchanged until commit. No instance directory or target JAR is created by this + /// operation. + /// + /// @param instanceId the instance receiving its own primary JAR + /// @param source the completed source JAR + /// @throws IOException if the source is not a regular file or its target escapes + /// the instance root + /// @throws NoSuchGameInstanceException if the draft does not contain `instanceId` + /// @throws IllegalStateException if the draft is not open + void putPrimaryJar(GameInstanceID instanceId, Path source) throws IOException; + /// Removes an instance from the unpublished draft state. /// /// Its instance root is retained until commit and moved out of the repository before the new @@ -110,7 +126,8 @@ enum State { /// @throws IllegalStateException if the draft is not open void rename(GameInstanceID from, GameInstanceID to) throws IOException; - /// Writes modified manifest files, then publishes a new immutable snapshot. + /// Materializes reserved instance roots, writes recorded primary JARs and modified manifests, + /// then publishes a new immutable snapshot. /// /// After this method returns, [GameRepository#getInstance(GameInstanceID)] will resolve modified /// ids from the published index. @@ -122,8 +139,9 @@ enum State { /// Discards pending changes without publishing a new snapshot. /// - /// Removes instance directories created only by this draft. Global caches (libraries, assets) - /// are not reverted. This method is idempotent after a successful abort. + /// Removes files placed under roots reserved by this draft by other installation work. Global + /// caches (libraries, assets) are not reverted. This method is idempotent after a successful + /// abort. /// /// @throws IOException if cleanup fails /// @throws IllegalStateException if the draft was already committed 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 5a21a656a1a..967a4cb3216 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 @@ -304,9 +304,17 @@ public void execute() throws Exception { Objects.equals(gameVersion, mainJarArtifact.getVersion()) && "client".equals(mainJarArtifact.getClassifier()) ) { - dependencies.add(new GameDownloadTask(dependencyManager, gameVersion, instanceManifest)); + dependencies.add(new GameDownloadTask( + dependencyManager, + gameVersion, + instanceManifest, + repository.getInstanceJar(instanceManifest))); } else { - dependencies.add(new GameDownloadTask(dependencyManager, null, instanceManifest)); + dependencies.add(new GameDownloadTask( + dependencyManager, + null, + instanceManifest, + repository.getInstanceJar(instanceManifest))); } } 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 2493de351e6..94974c8e549 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -200,7 +200,7 @@ public void testExplicitManifestDoesNotReuseDifferentCachedManifest(@TempDir Pat /// A game download with an explicit destination does not follow a later repository snapshot. @Test - public void testGameDownloadKeepsExplicitDestination(@TempDir Path tempDirectory) { + public void testGameDownloadKeepsExplicitDestination(@TempDir Path tempDirectory) throws IOException { TestRepository repository = new TestRepository(tempDirectory.resolve("game")); DefaultDependencyManager dependencyManager = new DefaultDependencyManager( repository, @@ -216,6 +216,27 @@ public void testGameDownloadKeepsExplicitDestination(@TempDir Path tempDirectory assertEquals(destination, download.getPath()); } + /// A versioned game download uses shared cache storage instead of the instance tree. + @Test + public void testGameDownloadUsesSharedDestination(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + Path cacheDirectory = tempDirectory.resolve("cache"); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(cacheDirectory)); + GameInstanceManifest manifest = new GameInstanceManifest(new GameInstanceID("instance")); + + GameDownloadTask task = new GameDownloadTask(dependencyManager, "1.21.1", manifest); + task.execute(); + + FileDownloadTask download = (FileDownloadTask) task.getDependencies().iterator().next(); + assertEquals( + cacheDirectory.resolve("jars/1.21.1.jar").toAbsolutePath().normalize(), + download.getPath()); + assertFalse(download.getPath().startsWith(repository.getLayout().getInstanceRoot(manifest.id()))); + } + /// Legacy verification fixes the captured instance jar rather than a newer same-id snapshot. @Test public void testVerificationFixKeepsCapturedInstance(@TempDir Path tempDirectory) throws IOException { 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 2c65215d501..5ca62408a56 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraftTest.java @@ -52,12 +52,14 @@ public void testCommitPublishesModifiedManifest(@TempDir Path tempDirectory) thr GameInstanceID id = new GameInstanceID("instance"); GameInstanceManifest manifest = new GameInstanceManifest(id).withMainClass("example.Main"); Path manifestFile = repository.getLayout().getInstanceJson(id); + Path instanceRoot = repository.getLayout().getInstanceRoot(id); Path draftStorage = tempDirectory.resolve(".hmcl").resolve("repository-drafts"); try (DefaultGameRepositoryDraft draft = repository.openDraft()) { draft.put(manifest); assertFalse(repository.hasInstance(id)); + assertFalse(Files.exists(instanceRoot)); assertFalse(Files.exists(manifestFile)); assertFalse(Files.exists(draftStorage)); @@ -74,6 +76,31 @@ public void testCommitPublishesModifiedManifest(@TempDir Path tempDirectory) thr assertEquals("example.Main", stored.mainClass()); } + /// Materializes a completed client JAR only while committing the new instance. + @Test + public void testCommitMaterializesPrimaryJar(@TempDir Path tempDirectory) throws IOException { + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest manifest = new GameInstanceManifest(id); + Path source = tempDirectory.resolve("cache/client.jar"); + Files.createDirectories(source.getParent()); + Files.writeString(source, "client"); + Path target = repository.getLayout().getInstanceJarFile(id); + + try (DefaultGameRepositoryDraft draft = repository.openDraft()) { + draft.put(manifest); + draft.putPrimaryJar(id, source); + + assertFalse(Files.exists(repository.getLayout().getInstanceRoot(id))); + assertFalse(Files.exists(target)); + + draft.commit(); + } + + assertEquals("client", Files.readString(target)); + assertEquals("client", Files.readString(source)); + } + /// Aborting removes files below a root that was first created by the draft. @Test public void testAbortRemovesDraftCreatedInstanceRoot(@TempDir Path tempDirectory) throws IOException { @@ -226,6 +253,30 @@ public void testCommitFailureRollsBackAndReleasesDraft(@TempDir Path tempDirecto } } + /// Restores an existing primary JAR when snapshot publication fails after replacement. + @Test + public void testCommitFailureRollsBackPrimaryJar(@TempDir Path tempDirectory) throws IOException { + FailingRepository repository = new FailingRepository(tempDirectory.resolve("game")); + GameInstanceID id = new GameInstanceID("instance"); + GameInstanceManifest original = new GameInstanceManifest(id).withMainClass("original.Main"); + repository.save(original); + Path target = repository.getLayout().getInstanceJarFile(id); + Files.writeString(target, "original"); + Path source = tempDirectory.resolve("cache/replacement.jar"); + Files.createDirectories(source.getParent()); + Files.writeString(source, "replacement"); + repository.failDraftPublish = true; + + DefaultGameRepositoryDraft draft = repository.openDraft(); + draft.put(original.withMainClass("updated.Main")); + draft.putPrimaryJar(id, source); + assertThrows(IllegalStateException.class, draft::commit); + + assertEquals("original", Files.readString(target)); + assertEquals("replacement", Files.readString(source)); + assertEquals(GameRepositoryDraft.State.FAILED, draft.getState()); + } + /// Runs an asynchronous update using the published instance as immutable context. @Test public void testUpdateInstanceAsyncCommitsWorkingManifest(@TempDir Path tempDirectory) throws Exception { From 11ff8d67d90b342fd55435b7a087983f87046e66 Mon Sep 17 00:00:00 2001 From: Glavo Date: Fri, 14 Aug 2026 21:34:52 +0800 Subject: [PATCH 172/199] Fix Minecraft processor version variable handling Assisted-by: codex:gpt-5.6-sol --- .../download/forge/ForgeNewInstallTask.java | 2 +- .../neoforge/NeoForgeOldInstallTask.java | 2 +- .../hmcl/game/DefaultGameInstanceTest.java | 82 +++++++++++++++++++ 3 files changed, 84 insertions(+), 2 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java index 1f9afb95a2a..9073cf82089 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java @@ -421,7 +421,7 @@ public void execute() throws Exception { vars.put("SIDE", "client"); vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(minecraftJar)); - vars.put("MINECRAFT_VERSION", FileUtils.getAbsolutePath(minecraftJar)); + vars.put("MINECRAFT_VERSION", profile.getMinecraft()); vars.put("ROOT", FileUtils.getAbsolutePath(gameRepository.getBaseDirectory())); vars.put("INSTALLER", installer.toAbsolutePath().toString()); vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLayout().getLibrariesDirectory())); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java index aa30e1a7053..87c3313ff38 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java @@ -405,7 +405,7 @@ public void execute() throws Exception { vars.put("SIDE", "client"); vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(minecraftJar)); - vars.put("MINECRAFT_VERSION", FileUtils.getAbsolutePath(minecraftJar)); + vars.put("MINECRAFT_VERSION", profile.getMinecraft()); vars.put("ROOT", FileUtils.getAbsolutePath(gameRepository.getBaseDirectory())); vars.put("INSTALLER", installer.toAbsolutePath().toString()); vars.put("LIBRARY_DIR", FileUtils.getAbsolutePath(gameRepository.getLayout().getLibrariesDirectory())); 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 94974c8e549..cb8fe71f2a2 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultCacheRepository; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.MojangDownloadProvider; +import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask; import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.download.game.GameVerificationFixTask; import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask; @@ -27,6 +28,8 @@ import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask; import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask; import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -38,6 +41,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; @@ -237,6 +241,46 @@ public void testGameDownloadUsesSharedDestination(@TempDir Path tempDirectory) t assertFalse(download.getPath().startsWith(repository.getLayout().getInstanceRoot(manifest.id()))); } + /// Keeps the detached client JAR path distinct from the Minecraft version processor variable. + @Test + public void testForgeProcessorSeparatesMinecraftJarAndVersion(@TempDir Path tempDirectory) + throws IOException { + Path versionMarker = tempDirectory.resolve("minecraft-version"); + Files.writeString(versionMarker, "version"); + String markerSha1 = DigestUtils.digestToString("SHA-1", versionMarker); + + Path minecraftJar = tempDirectory.resolve("cache/client.jar"); + Files.createDirectories(minecraftJar.getParent()); + Files.writeString(minecraftJar, "client"); + + Path installer = tempDirectory.resolve("forge-installer.jar"); + writeForgeProcessorFixture( + installer, + versionMarker.toAbsolutePath().normalize().toString(), + markerSha1); + + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("download-cache"))); + ForgeNewInstallTask task = new ForgeNewInstallTask( + dependencyManager, + new GameInstanceManifest(new GameInstanceID("instance")), + minecraftJar, + "forge-test", + installer) { + @Override + protected void updateProgressImmediately(double progress) { + // Avoid JavaFX toolkit initialization in this isolated processor test. + } + }; + + var executor = task.executor(); + assertTrue(executor.test(), () -> String.valueOf(executor.getException())); + assertTrue(Files.isRegularFile(minecraftJar)); + } + /// Legacy verification fixes the captured instance jar rather than a newer same-id snapshot. @Test public void testVerificationFixKeepsCapturedInstance(@TempDir Path tempDirectory) throws IOException { @@ -337,6 +381,44 @@ private static void writeSignedJar(Path jar) throws IOException { } } + /// Writes a Forge installer fixture whose processor output is keyed by + /// `{MINECRAFT_VERSION}`. + /// + /// @param installer the installer JAR path + /// @param minecraftVersion the value stored in the install profile's `minecraft` field + /// @param outputSha1 expected checksum for the processor output + private static void writeForgeProcessorFixture( + Path installer, + String minecraftVersion, + String outputSha1) throws IOException { + Map profile = Map.of( + "spec", 1, + "minecraft", minecraftVersion, + "json", "version.json", + "version", "forge-test", + "libraries", List.of(), + "processors", List.of(Map.of( + "jar", "example:processor:1.0", + "outputs", Map.of("{MINECRAFT_VERSION}", outputSha1)))); + + try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(installer))) { + writeZipEntry(output, "install_profile.json", JsonUtils.GSON.toJson(profile)); + writeZipEntry(output, "version.json", "{\"id\":\"forge-test\",\"libraries\":[]}"); + } + } + + /// Writes one UTF-8 text entry to a ZIP stream. + /// + /// @param output the destination ZIP stream + /// @param name the entry name + /// @param content the entry content + private static void writeZipEntry(ZipOutputStream output, String name, String content) + throws IOException { + output.putNextEntry(new ZipEntry(name)); + output.write(content.getBytes(StandardCharsets.UTF_8)); + output.closeEntry(); + } + /// Returns whether a zip contains an entry with the given name. /// /// @param zipFile the zip path From 9f5e8ac905920f761283534c57bafb14188a1365 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 20:18:28 +0800 Subject: [PATCH 173/199] fix: update main class reference in preExecute method for compatibility check --- .../org/jackhuang/hmcl/download/fabric/FabricInstallTask.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d48b558761b..090596ede18 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/fabric/FabricInstallTask.java @@ -62,7 +62,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, dependencyManager.getGameRepository().resolve(manifest).launchManifest().mainClass())) throw new UnsupportedInstallationException(FABRIC_NOT_COMPATIBLE_WITH_FORGE); } From 556eeec7a8ab93aa0774939bd746b83041a2fbba Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 20:22:52 +0800 Subject: [PATCH 174/199] Fix checkstyle --- .../org/jackhuang/hmcl/game/HMCLGameInstance.java | 12 ++++-------- .../org/jackhuang/hmcl/game/HMCLGameRepository.java | 1 - .../hmcl/download/game/GameAssetDownloadTask.java | 1 - .../hmcl/download/game/GameVerificationFixTask.java | 1 - 4 files changed, 4 insertions(+), 11 deletions(-) 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 e4294e7f63e..4354d77750d 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameInstance.java @@ -690,14 +690,10 @@ private static LoadResult loadGameSettingsFile(Path file) { JsonSchema.check(jsonObject, GameSettings.Instance.CURRENT_SCHEMA); switch (schemaResult.status()) { case MISSING -> LOG.warning("Missing schema in instance game settings: " + file); - case INVALID -> - LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue())); - case UNPARSEABLE -> - LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual())); - case UNEXPECTED_ID -> - LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); - case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> - LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); + case INVALID -> LOG.warning("Invalid schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.invalidValue())); + case UNPARSEABLE -> LOG.warning("Unparseable schema in instance game settings: %s, Actual: %s".formatted(file, schemaResult.actual())); + case UNEXPECTED_ID -> LOG.warning("Unexpected instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); + case UNSUPPORTED_MAJOR, READ_ONLY_PRESERVE_SCHEMA -> LOG.warning("Unsupported instance game settings schema. Expected: %s, Actual: %s".formatted(GameSettings.Instance.CURRENT_SCHEMA, schemaResult.actual())); case READ_WRITE, READ_WRITE_PRESERVE_SCHEMA -> { } } 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 e3c7af96664..c7c15b333eb 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLGameRepository.java @@ -35,7 +35,6 @@ import org.jackhuang.hmcl.setting.GameSettingsPresetID; import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.StringUtils; -import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.platform.Bits; import org.jackhuang.hmcl.util.platform.OperatingSystem; 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 4496f133a3c..01be3d36359 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameAssetDownloadTask.java @@ -49,7 +49,6 @@ public final class GameAssetDownloadTask extends Task { private final List> dependents = new ArrayList<>(1); private final List> dependencies = new ArrayList<>(); - /// Constructor. /// /// @param dependencyManager the dependency manager that can provides [GameRepository] diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java index d3d4ab67373..8b3b5c969df 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java @@ -17,7 +17,6 @@ */ package org.jackhuang.hmcl.download.game; -import org.jackhuang.hmcl.game.GameComponentAnalyzer; import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; From 9a3d183db9f8aad89382911e8a7e71bdae8523b0 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 20:24:03 +0800 Subject: [PATCH 175/199] fix: replace analyzer usage with instance method for component check --- .../jackhuang/hmcl/download/game/GameVerificationFixTask.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java index 8b3b5c969df..510073482fb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameVerificationFixTask.java @@ -63,9 +63,8 @@ public GameVerificationFixTask(GameInstance instance, GameVersionNumber gameVers @Override public void execute() throws IOException { Path jar = instance.getInstanceJarFile(); - var analyzer = instance.getAnalyzer(); - if (Files.exists(jar) && gameVersion.compareTo("1.6") < 0 && analyzer.has(GameComponentType.FORGE)) { + if (Files.exists(jar) && gameVersion.compareTo("1.6") < 0 && instance.hasComponent(GameComponentType.FORGE)) { try (FileSystem fs = CompressingUtils.createWritableZipFileSystem(jar, StandardCharsets.UTF_8)) { Files.deleteIfExists(fs.getPath("META-INF/MOJANG_C.DSA")); Files.deleteIfExists(fs.getPath("META-INF/MOJANG_C.SF")); From d9daf7a374ed3c91183039271435155c35c3d1fd Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 20:45:37 +0800 Subject: [PATCH 176/199] fix: simplify draft handling in buildAsync method and remove unused AtomicReference --- .../hmcl/download/DefaultGameBuilder.java | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) 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 956001dc91e..63c72c9195b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -29,7 +29,6 @@ import java.util.ArrayList; import java.util.Map; import java.util.Objects; -import java.util.concurrent.atomic.AtomicReference; /// Builds a new game instance in an exclusive [GameRepositoryDraft], installs its components, and /// publishes the completed instance once. @@ -65,7 +64,7 @@ public DefaultDependencyManager getDependencyManager() { /// @throws NullPointerException if [#name] was not set @Override public Task buildAsync() { - Objects.requireNonNull(name, "GameBuilder.name must be set"); + GameInstanceID name = Objects.requireNonNull(this.name, "GameBuilder.name must be set"); var hints = new ArrayList(); hints.add(new Task.StagesHint("hmcl.install.game:" + gameVersion)); @@ -83,14 +82,12 @@ public Task buildAsync() { } DefaultGameRepository repository = dependencyManager.getGameRepository(); - AtomicReference activeDraft = new AtomicReference<>(); - return Task.supplyAsync(() -> { - GameRepositoryDraft draft = repository.openDraft(); - activeDraft.set(draft); - return new GameInstanceManifest(name); - }) - .thenComposeAsync(initialManifest -> { + //noinspection resource + GameRepositoryDraft draft = repository.openDraft(); + return Task.composeAsync(() -> { + GameInstanceManifest initialManifest = new GameInstanceManifest(name); + Task libraryTask = Task.supplyAsync(() -> initialManifest); libraryTask = libraryTask.thenComposeAsync( libraryTaskHelper(name, gameVersion, "game", gameVersion)); @@ -109,18 +106,13 @@ public Task buildAsync() { return libraryTask.thenComposeAsync(manifest -> new GameDownloadTask(dependencyManager, gameVersion, manifest) .thenApplyAsync(minecraftJar -> { - GameRepositoryDraft draft = activeDraft.get(); - if (draft == null) { - throw new IllegalStateException("Game repository draft is unavailable"); - } draft.put(manifest); draft.putPrimaryJar(name, minecraftJar); return draft.commit().getInstance(name); })); }) .whenComplete(exception -> { - GameRepositoryDraft draft = activeDraft.getAndSet(null); - if (draft != null && draft.isOpen()) { + if (draft.isOpen()) { draft.abort(); } }) From 7cd61dbc3a543ffc4de22c6950260669da5e04bd Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 20:50:41 +0800 Subject: [PATCH 177/199] fix: simplify draft handling in buildAsync method and remove unused AtomicReference --- .../hmcl/game/HMCLModpackInstallTask.java | 2 +- .../hmcl/ui/download/DownloadPage.java | 2 +- .../org/jackhuang/hmcl/ui/main/MainPage.java | 2 +- .../hmcl/download/DefaultGameBuilder.java | 16 ++++++------ .../jackhuang/hmcl/download/GameBuilder.java | 26 ++++++++----------- .../hmcl/modpack/curse/CurseInstallTask.java | 2 +- .../mcbbs/McbbsModpackLocalInstallTask.java | 2 +- .../modpack/modrinth/ModrinthInstallTask.java | 2 +- .../server/ServerModpackCompletionTask.java | 2 +- .../server/ServerModpackLocalInstallTask.java | 2 +- .../ServerModpackRemoteInstallTask.java | 2 +- 11 files changed, 28 insertions(+), 32 deletions(-) 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 cdfc80d1679..31372d870ae 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -55,7 +55,7 @@ public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpa if (repository.hasInstance(this.instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists"); - dependents.add(dependency.newGameBuilder().name(this.instanceId).gameVersion(modpack.getGameVersion()).buildAsync()); + dependents.add(dependency.newGameBuilder().id(this.instanceId).gameVersion(modpack.getGameVersion()).buildAsync()); onDone().register(event -> { if (event.isFailed()) repository.removeInstanceFromDisk(this.instanceId); 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 043ca8fa9d0..04aa625d8e5 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 @@ -310,7 +310,7 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { GameBuilder builder = dependencyManager.newGameBuilder(); GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID); - builder.name(instanceId); + builder.id(instanceId); builder.gameVersion(((RemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion()); settings.asStringMap().forEach((key, value) -> { 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 e6dc1bad4fe..d8f290f51b7 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 @@ -381,7 +381,7 @@ private void launchNoGame() { instanceHolder.value = instanceId; return dependency.newGameBuilder() - .name(instanceId) + .id(instanceId) .gameVersion(gameVersion) .buildAsync(); }) 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 63c72c9195b..d1200776c50 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -61,10 +61,10 @@ public DefaultDependencyManager getDependencyManager() { /// stages the completed manifest, and commits it once. Failure or cancellation aborts the draft. /// /// @return the build task - /// @throws NullPointerException if [#name] was not set + /// @throws NullPointerException if [#id] was not set @Override public Task buildAsync() { - GameInstanceID name = Objects.requireNonNull(this.name, "GameBuilder.name must be set"); + GameInstanceID id = Objects.requireNonNull(this.id, "GameBuilder.id must be set"); var hints = new ArrayList(); hints.add(new Task.StagesHint("hmcl.install.game:" + gameVersion)); @@ -86,29 +86,29 @@ public Task buildAsync() { //noinspection resource GameRepositoryDraft draft = repository.openDraft(); return Task.composeAsync(() -> { - GameInstanceManifest initialManifest = new GameInstanceManifest(name); + GameInstanceManifest initialManifest = new GameInstanceManifest(id); Task libraryTask = Task.supplyAsync(() -> initialManifest); libraryTask = libraryTask.thenComposeAsync( - libraryTaskHelper(name, gameVersion, "game", gameVersion)); + libraryTaskHelper(id, gameVersion, "game", gameVersion)); for (Map.Entry entry : toolVersions.entrySet()) { libraryTask = libraryTask.thenComposeAsync( - libraryTaskHelper(name, gameVersion, entry.getKey(), entry.getValue())); + libraryTaskHelper(id, gameVersion, entry.getKey(), entry.getValue())); } for (RemoteVersion remoteVersion : remoteVersions) { libraryTask = libraryTask.thenComposeAsync(working -> dependencyManager.installNewInstanceComponentAsync( - name, working, gameVersion, remoteVersion)); + id, working, gameVersion, remoteVersion)); } return libraryTask.thenComposeAsync(manifest -> new GameDownloadTask(dependencyManager, gameVersion, manifest) .thenApplyAsync(minecraftJar -> { draft.put(manifest); - draft.putPrimaryJar(name, minecraftJar); - return draft.commit().getInstance(name); + draft.putPrimaryJar(id, minecraftJar); + return draft.commit().getInstance(id); })); }) .whenComplete(exception -> { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java index c16c19d6003..f8d517edadd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java @@ -23,29 +23,25 @@ import java.util.*; -/** - * The builder which provide a task to build Minecraft environment. - * - * @author huangyuhui - */ +/// The builder which provide a task to build Minecraft environment. +/// +/// @author huangyuhui public abstract class GameBuilder { - protected @Nullable GameInstanceID name; + protected @Nullable GameInstanceID id; protected String gameVersion = ""; protected final Map toolVersions = new HashMap<>(); protected final Set remoteVersions = new HashSet<>(); - public GameInstanceID getName() { - return name; + public GameInstanceID getId() { + return id; } - /** - * The new game version name, for .minecraft/<version name>. - * - * @param name the name of new game version. - */ - public GameBuilder name(GameInstanceID name) { - this.name = Objects.requireNonNull(name); + /// The new game version name, for `.minecraft/`. + /// + /// @param id the name of new game version. + public GameBuilder id(GameInstanceID id) { + this.id = Objects.requireNonNull(id); return this; } 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 4414dab2e04..9246a7b7cc5 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 @@ -83,7 +83,7 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId).gameVersion(manifest.minecraft().gameVersion()); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId).gameVersion(manifest.minecraft().gameVersion()); for (CurseManifestModLoader modLoader : manifest.minecraft().modLoaders()) { if (modLoader.id().startsWith("forge-")) { builder.version("forge", modLoader.id().substring("forge-".length())); 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 71366355656..fb275a9d76d 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 @@ -67,7 +67,7 @@ public McbbsModpackLocalInstallTask(DefaultDependencyManager dependencyManager, this.update = repository.hasInstance(instanceId); - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); for (McbbsModpackManifest.Addon addon : manifest.getAddons()) { builder.version(addon.getId(), addon.getVersion()); } 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 0fb2196df01..36baff92bee 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 @@ -68,7 +68,7 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId).gameVersion(manifest.getGameVersion()); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId).gameVersion(manifest.getGameVersion()); for (Map.Entry modLoader : manifest.getDependencies().entrySet()) { switch (modLoader.getKey()) { case "minecraft": 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 b056d69e8ea..6e09629d747 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 @@ -141,7 +141,7 @@ public void execute() throws Exception { Map oldAddons = toMap(manifest.getManifest().getAddons()); Map newAddons = toMap(remoteManifest.getAddons()); if (!Objects.equals(oldAddons, newAddons)) { - GameBuilder builder = dependencyManager.newGameBuilder().name(instance.getId()); + GameBuilder builder = dependencyManager.newGameBuilder().id(instance.getId()); for (ServerModpackManifest.Addon addon : remoteManifest.getAddons()) { builder.version(addon.getId(), addon.getVersion()); } 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 81730b1e859..21e1a9bcf95 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 @@ -58,7 +58,7 @@ public ServerModpackLocalInstallTask(DefaultDependencyManager dependencyManager, if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); for (ServerModpackManifest.Addon addon : manifest.getAddons()) { builder.version(addon.getId(), addon.getVersion()); } 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 c56831da127..1180073e790 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 @@ -52,7 +52,7 @@ public ServerModpackRemoteInstallTask(DefaultDependencyManager dependencyManager if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); - GameBuilder builder = dependencyManager.newGameBuilder().name(instanceId); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); for (ServerModpackManifest.Addon addon : manifest.getAddons()) { builder.version(addon.getId(), addon.getVersion()); } From 1e4783921e30958f2d908202baa4cbf9775cd34e Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:01:12 +0800 Subject: [PATCH 178/199] fix: streamline game instance component installation in buildAsync method --- .../hmcl/download/DefaultGameBuilder.java | 62 ++++++------------- 1 file changed, 20 insertions(+), 42 deletions(-) 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 d1200776c50..de9a63c9bcd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -18,10 +18,7 @@ package org.jackhuang.hmcl.download; import org.jackhuang.hmcl.download.game.GameDownloadTask; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameRepositoryDraft; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.function.ExceptionalFunction; import org.jetbrains.annotations.NotNullByDefault; @@ -82,35 +79,31 @@ public Task buildAsync() { } DefaultGameRepository repository = dependencyManager.getGameRepository(); - //noinspection resource GameRepositoryDraft draft = repository.openDraft(); - return Task.composeAsync(() -> { - GameInstanceManifest initialManifest = new GameInstanceManifest(id); + GameInstanceManifest initialManifest = new GameInstanceManifest(id); - Task libraryTask = Task.supplyAsync(() -> initialManifest); - libraryTask = libraryTask.thenComposeAsync( - libraryTaskHelper(id, gameVersion, "game", gameVersion)); + Task libraryTask = dependencyManager.installNewInstanceComponentAsync( + id, new GameInstanceManifest(id), gameVersion, GameComponentType.GAME.getPatchId(), gameVersion); - for (Map.Entry entry : toolVersions.entrySet()) { - libraryTask = libraryTask.thenComposeAsync( - libraryTaskHelper(id, gameVersion, entry.getKey(), entry.getValue())); - } + for (Map.Entry entry : toolVersions.entrySet()) { + libraryTask = libraryTask.thenComposeAsync(manifest -> dependencyManager.installNewInstanceComponentAsync( + id, manifest, gameVersion, entry.getKey(), entry.getValue())); + } - for (RemoteVersion remoteVersion : remoteVersions) { - libraryTask = libraryTask.thenComposeAsync(working -> - dependencyManager.installNewInstanceComponentAsync( - id, working, gameVersion, remoteVersion)); - } + for (RemoteVersion remoteVersion : remoteVersions) { + libraryTask = libraryTask.thenComposeAsync(manifest -> + dependencyManager.installNewInstanceComponentAsync( + id, manifest, gameVersion, remoteVersion)); + } - return libraryTask.thenComposeAsync(manifest -> - new GameDownloadTask(dependencyManager, gameVersion, manifest) - .thenApplyAsync(minecraftJar -> { - draft.put(manifest); - draft.putPrimaryJar(id, minecraftJar); - return draft.commit().getInstance(id); - })); - }) + return libraryTask.thenComposeAsync(manifest -> + new GameDownloadTask(dependencyManager, gameVersion, manifest) + .thenApplyAsync(minecraftJar -> { + draft.put(manifest); + draft.putPrimaryJar(id, minecraftJar); + return draft.commit().getInstance(id); + })) .whenComplete(exception -> { if (draft.isOpen()) { draft.abort(); @@ -119,19 +112,4 @@ public Task buildAsync() { .withStagesHints(hints); } - /// Returns a step that installs one remote component into the working manifest. - /// - /// @param instanceId the unpublished instance id - /// @param gameVersion the Minecraft version used to look up the remote list - /// @param libraryId the component list id - /// @param libraryVersion the component version id - /// @return a function from the current working manifest to the install task - private ExceptionalFunction, ?> libraryTaskHelper( - GameInstanceID instanceId, - String gameVersion, - String libraryId, - String libraryVersion) { - return working -> dependencyManager.installNewInstanceComponentAsync( - instanceId, working, gameVersion, libraryId, libraryVersion); - } } From 42581826e9203ad179f15dd3a348473144816f7c Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:01:25 +0800 Subject: [PATCH 179/199] fix: remove unused GameInstanceManifest initialization in DefaultGameBuilder --- .../java/org/jackhuang/hmcl/download/DefaultGameBuilder.java | 2 -- 1 file changed, 2 deletions(-) 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 de9a63c9bcd..1420a6e5b59 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -20,7 +20,6 @@ import org.jackhuang.hmcl.download.game.GameDownloadTask; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.function.ExceptionalFunction; import org.jetbrains.annotations.NotNullByDefault; import java.util.ArrayList; @@ -81,7 +80,6 @@ public Task buildAsync() { DefaultGameRepository repository = dependencyManager.getGameRepository(); //noinspection resource GameRepositoryDraft draft = repository.openDraft(); - GameInstanceManifest initialManifest = new GameInstanceManifest(id); Task libraryTask = dependencyManager.installNewInstanceComponentAsync( id, new GameInstanceManifest(id), gameVersion, GameComponentType.GAME.getPatchId(), gameVersion); From ec744b74c1d5beeffaff3384c03c41b15c3e3933 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:09:43 +0800 Subject: [PATCH 180/199] feat: implement getVersionList method for various GameComponentTypes in download providers --- .../hmcl/download/AutoDownloadProvider.java | 7 +++++ .../download/BMCLAPIDownloadProvider.java | 20 +++++++++++++ .../hmcl/download/DownloadProvider.java | 8 +++++ .../download/DownloadProviderWrapper.java | 30 +++++++++++++++++++ .../hmcl/download/MojangDownloadProvider.java | 20 +++++++++++++ 5 files changed, 85 insertions(+) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java index 78513ad59ea..7078dd9389c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; + import java.net.URI; import java.util.LinkedHashSet; import java.util.List; @@ -93,6 +95,11 @@ public List injectURLsWithCandidates(List urls) { return getAll(fileProviders, provider -> provider.injectURLsWithCandidates(urls)); } + @Override + public VersionList getVersionList(GameComponentType componentType) { + return null; // TODO + } + @Override public VersionList getVersionListById(String id) { return versionLists.computeIfAbsent(id, value -> { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java index b23a7867f7b..39a31371e34 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java @@ -29,6 +29,7 @@ import org.jackhuang.hmcl.download.optifine.OptiFineBMCLVersionList; import org.jackhuang.hmcl.download.quilt.QuiltAPIVersionList; import org.jackhuang.hmcl.download.quilt.QuiltVersionList; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.io.NetworkUtils; @@ -119,6 +120,25 @@ public List getAssetObjectCandidates(String assetObjectLocation) { return List.of(NetworkUtils.toURI(apiRoot + "/assets/" + assetObjectLocation)); } + @Override + public VersionList getVersionList(GameComponentType componentType) { + return switch (componentType) { + case GAME -> game; + case FABRIC -> fabric; + case FABRIC_API -> fabricApi; + case FORGE -> forge; + case CLEANROOM -> cleanroom; + case NEO_FORGE -> neoforge; + case LITELOADER -> liteLoader; + case OPTIFINE -> optifine; + case QUILT -> quilt; + case QUILT_API -> quiltApi; + case LEGACY_FABRIC -> legacyFabric; + case LEGACY_FABRIC_API -> legacyFabricApi; + case BOOTSTRAP_LAUNCHER -> throw new IllegalArgumentException("Unrecognized component: " + componentType); + }; + } + @Override public VersionList getVersionListById(String id) { return switch (id) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java index 0c6117e491b..31588d432e9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.util.io.NetworkUtils; import java.net.URI; @@ -60,6 +61,13 @@ default List injectURLsWithCandidates(List urls) { return List.copyOf(result); } + /// the specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" + /// + /// @param componentType the component type of specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" + /// @return the version list + /// @throws IllegalArgumentException if the version list does not exist + VersionList getVersionList(GameComponentType componentType); + /// the specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" /// /// @param id the id of specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java index 770edbc3108..5f04a3ca7fb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.task.Task; import java.net.URI; @@ -67,6 +68,35 @@ public List injectURLsWithCandidates(List urls) { return getProvider().injectURLsWithCandidates(urls); } + @Override + public VersionList getVersionList(GameComponentType componentType) { + return new VersionList<>() { + @Override + public boolean hasType() { + return getProvider().getVersionList(componentType).hasType(); + } + + @Override + public Task refreshAsync() { + throw new UnsupportedOperationException(); + } + + @Override + public Task refreshAsync(String gameVersion) { + return getProvider().getVersionList(componentType).refreshAsync(gameVersion) + .thenComposeAsync(() -> { + lock.writeLock().lock(); + try { + versions.putAll(gameVersion, getProvider().getVersionList(componentType).getVersions(gameVersion)); + } finally { + lock.writeLock().unlock(); + } + return null; + }); + } + }; + } + @Override public VersionList getVersionListById(String id) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java index 716acbcc399..e9d2dc210a9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java @@ -29,6 +29,7 @@ import org.jackhuang.hmcl.download.optifine.OptiFineBMCLVersionList; import org.jackhuang.hmcl.download.quilt.QuiltAPIVersionList; import org.jackhuang.hmcl.download.quilt.QuiltVersionList; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.util.io.NetworkUtils; import java.net.URI; @@ -80,6 +81,25 @@ public List getAssetObjectCandidates(String assetObjectLocation) { return List.of(NetworkUtils.toURI("https://resources.download.minecraft.net/" + assetObjectLocation)); } + @Override + public VersionList getVersionList(GameComponentType componentType) { + return switch (componentType) { + case GAME -> game; + case FABRIC -> fabric; + case FABRIC_API -> fabricApi; + case FORGE -> forge; + case CLEANROOM -> cleanroom; + case NEO_FORGE -> neoforge; + case LITELOADER -> liteLoader; + case OPTIFINE -> optifine; + case QUILT -> quilt; + case QUILT_API -> quiltApi; + case LEGACY_FABRIC -> legacyFabric; + case LEGACY_FABRIC_API -> legacyFabricApi; + case BOOTSTRAP_LAUNCHER -> throw new IllegalArgumentException("Unrecognized component: " + componentType); + }; + } + @Override public VersionList getVersionListById(String id) { return switch (id) { From 93c8a76ed12eeb9786a00a2d10385cda04bc7bac Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:12:16 +0800 Subject: [PATCH 181/199] fix: simplify exception handling with pattern matching in alertFailureMessage method --- .../UpdateInstallerWizardProvider.java | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) 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 875fb25f6ca..b29a815f582 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 @@ -135,8 +135,7 @@ public boolean cancelIfCannotGoBack() { public static void alertFailureMessage(Exception exception, Runnable next) { if (exception instanceof LibraryDownloadException) { String message = i18n("launch.failed.download_library", ((LibraryDownloadException) exception).getLibrary().name()) + "\n"; - if (exception.getCause() instanceof ResponseCodeException) { - ResponseCodeException rce = (ResponseCodeException) exception.getCause(); + if (exception.getCause() instanceof ResponseCodeException rce) { int responseCode = rce.getResponseCode(); String uri = rce.getUri(); if (responseCode == 404) @@ -151,8 +150,7 @@ public static void alertFailureMessage(Exception exception, Runnable next) { URI uri = ((DownloadException) exception).getUri(); if (exception.getCause() instanceof SocketTimeoutException) { Controllers.dialog(i18n("install.failed.downloading.timeout", uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next); - } else if (exception.getCause() instanceof ResponseCodeException) { - ResponseCodeException responseCodeException = (ResponseCodeException) exception.getCause(); + } else if (exception.getCause() instanceof ResponseCodeException responseCodeException) { if (I18n.hasKey("download.code." + responseCodeException.getResponseCode())) { Controllers.dialog(i18n("download.code." + responseCodeException.getResponseCode(), uri), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next); } else { @@ -161,14 +159,12 @@ public static void alertFailureMessage(Exception exception, Runnable next) { } else { Controllers.dialog(i18n("install.failed.downloading.detail", uri) + "\n" + StringUtils.getStackTrace(exception.getCause()), i18n("install.failed.downloading"), MessageDialogPane.MessageType.ERROR, next); } - } else if (exception instanceof UnsupportedInstallationException) { - switch (((UnsupportedInstallationException) exception).getReason()) { - case UnsupportedInstallationException.FORGE_1_17_OPTIFINE_H1_PRE2: - Controllers.dialog(i18n("install.failed.optifine_forge_1.17"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); - break; - default: - Controllers.dialog(i18n("install.failed.optifine_conflict"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); - break; + } else if (exception instanceof UnsupportedInstallationException unsupportedInstallationException) { + switch (unsupportedInstallationException.getReason()) { + case UnsupportedInstallationException.FORGE_1_17_OPTIFINE_H1_PRE2 -> + Controllers.dialog(i18n("install.failed.optifine_forge_1.17"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); + default -> + Controllers.dialog(i18n("install.failed.optifine_conflict"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); } } else if (exception instanceof DefaultDependencyManager.UnsupportedLibraryInstallerException) { Controllers.dialog(i18n("install.failed.install_online"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); @@ -176,8 +172,7 @@ public static void alertFailureMessage(Exception exception, Runnable next) { Controllers.dialog(i18n("install.failed.malformed"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); } else if (exception instanceof GameAssetIndexDownloadTask.GameAssetIndexMalformedException) { Controllers.dialog(i18n("assets.index.malformed"), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); - } else if (exception instanceof VersionMismatchException) { - VersionMismatchException e = ((VersionMismatchException) exception); + } else if (exception instanceof VersionMismatchException e) { Controllers.dialog(i18n("install.failed.version_mismatch", e.getExpect(), e.getActual()), i18n("install.failed"), MessageDialogPane.MessageType.ERROR, next); } else if (exception instanceof CancellationException) { // Ignore cancel From 04f3092ead23015ac90b42e5824370ab01f5abdc Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:16:24 +0800 Subject: [PATCH 182/199] refactor: replace string libraryId with GameComponentType in installer-related classes --- .../hmcl/ui/download/AbstractInstallersPage.java | 2 +- .../jackhuang/hmcl/ui/download/DownloadPage.java | 2 +- .../download/UpdateInstallerWizardProvider.java | 14 +++++++------- .../jackhuang/hmcl/ui/download/VersionsPage.java | 15 ++++++++------- .../hmcl/ui/instances/InstallerListPage.java | 2 +- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java index cff5780a286..ddaccb0cb20 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/AbstractInstallersPage.java @@ -83,7 +83,7 @@ public AbstractInstallersPage(WizardController controller, String gameVersion, D i18n("install.installer.choose", i18n("install.installer." + type.getPatchId())), gameVersion, downloadProvider, - type.getPatchId(), + type, () -> controller.onPrev(false, Navigation.NavigationDirection.PREVIOUS) ), Navigation.NavigationDirection.NEXT ); 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 04aa625d8e5..4b15a2634cd 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 @@ -96,7 +96,7 @@ public DownloadPage() { public DownloadPage(GameInstanceID uploadInstance) { newGameTab.setNodeSupplier(loadVersionFor(() -> new VersionsPage(versionPageNavigator, i18n("install.installer.choose", i18n("install.installer.game")), "", DownloadProviders.getDownloadProvider(), - "game", versionPageNavigator::onGameSelected))); + GameComponentType.GAME, versionPageNavigator::onGameSelected))); modpackTab.setNodeSupplier(loadVersionFor(() -> { DownloadListPage page = HMCLLocalizedDownloadListPage.ofModPack((downloadProvider, repository, __, modpack, file) -> { Instances.downloadModpackImpl(downloadProvider, repository, uploadInstance, modpack, file); 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 b29a815f582..e6489fc778d 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 @@ -49,13 +49,13 @@ public final class UpdateInstallerWizardProvider implements WizardProvider { private final HMCLGameInstance gameInstance; private final DefaultDependencyManager dependencyManager; - private final String libraryId; + private final GameComponentType componentType; private final String oldLibraryVersion; private final DownloadProvider downloadProvider; - public UpdateInstallerWizardProvider(@NotNull HMCLGameInstance gameInstance, @NotNull String libraryId, @Nullable String oldLibraryVersion) { + public UpdateInstallerWizardProvider(@NotNull HMCLGameInstance gameInstance, @NotNull GameComponentType componentType, @Nullable String oldLibraryVersion) { this.gameInstance = gameInstance; - this.libraryId = libraryId; + this.componentType = componentType; this.oldLibraryVersion = oldLibraryVersion; this.downloadProvider = DownloadProviders.getDownloadProvider(); this.dependencyManager = gameInstance.getRepository().getDependency(downloadProvider); @@ -104,14 +104,14 @@ public Object finish(SettingsMap settings) { public Node createPage(WizardController controller, int step, SettingsMap settings) { switch (step) { case 0: - return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + libraryId)), gameInstance.getVersion().toString(), downloadProvider, libraryId, () -> { + return new VersionsPage(controller, i18n("install.installer.choose", i18n("install.installer." + componentType)), gameInstance.getVersion().toString(), downloadProvider, componentType, () -> { if (oldLibraryVersion == null) { controller.onFinish(); - } else if ("game".equals(libraryId)) { - String newGameVersion = ((RemoteVersion) settings.get(libraryId)).getSelfVersion(); + } else if (componentType == GameComponentType.GAME) { + String newGameVersion = ((RemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion(); controller.onNext(new AdditionalInstallersPage(gameInstance, newGameVersion, controller, downloadProvider)); } else { - Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + libraryId), oldLibraryVersion, ((RemoteVersion) settings.get(libraryId)).getSelfVersion()), + Controllers.confirm(i18n("install.change_version.confirm", i18n("install.installer." + componentType), oldLibraryVersion, ((RemoteVersion) settings.get(componentType.getPatchId())).getSelfVersion()), i18n("install.change_version"), controller::onFinish, controller::onCancel); } }); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java index 69a79bb7fc9..82218e8dac5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/VersionsPage.java @@ -45,6 +45,7 @@ import org.jackhuang.hmcl.download.optifine.OptiFineRemoteVersion; import org.jackhuang.hmcl.download.quilt.QuiltAPIRemoteVersion; import org.jackhuang.hmcl.download.quilt.QuiltRemoteVersion; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.setting.GameInstanceIconType; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -77,7 +78,7 @@ public final class VersionsPage extends Control implements WizardPage, Refreshable { private final String gameVersion; - private final String libraryId; + private final GameComponentType componentType; private final String title; private final Navigation navigation; private final DownloadProvider downloadProvider; @@ -90,14 +91,14 @@ public final class VersionsPage extends Control implements WizardPage, Refreshab public VersionsPage(Navigation navigation, String title, String gameVersion, DownloadProvider downloadProvider, - String libraryId, + GameComponentType componentType, Runnable callback) { this.title = title; this.gameVersion = gameVersion; - this.libraryId = libraryId; + this.componentType = componentType; this.navigation = navigation; this.downloadProvider = downloadProvider; - this.versionList = downloadProvider.getVersionListById(libraryId); + this.versionList = downloadProvider.getVersionList(componentType); this.callback = callback; refresh(); @@ -173,7 +174,7 @@ private static class RemoteVersionListCell extends ListCell { HBox actions = new HBox(8); actions.setAlignment(Pos.CENTER); { - if ("game".equals(control.libraryId)) { + if (control.componentType == GameComponentType.GAME) { JFXButton wikiButton = newToggleButton4(SVG.GLOBE_BOOK); wikiButton.setOnAction(event -> onOpenWiki()); FXUtils.installFastTooltip(wikiButton, i18n("wiki.tooltip")); @@ -199,7 +200,7 @@ private void onAction() { if (item == null) return; - control.navigation.getSettings().put(control.libraryId, item); + control.navigation.getSettings().put(control.componentType.getPatchId(), item); control.callback.run(); } @@ -339,7 +340,7 @@ private static final class VersionsPageSkin extends SkinBase { nameField.setPromptText(i18n("instance.search.prompt")); nameField.textProperty().addListener(o -> updateList()); - if ("game".equals(control.libraryId)) { + if (control.componentType == GameComponentType.GAME) { categoryField.getItems().setAll( VersionTypeFilter.ALL, VersionTypeFilter.RELEASE, diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java index 34f9ea44fe7..ff38c42dc6f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/InstallerListPage.java @@ -102,7 +102,7 @@ public void loadInstance(HMCLGameInstance.Optional instance) { } component.setOnInstall(() -> { - Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType().getPatchId(), libraryVersion)); + Controllers.getDecorator().startWizard(new UpdateInstallerWizardProvider(gameInstance, component.getComponentType(), libraryVersion)); }); component.setOnRemove(() -> repository.updateInstanceAsync( From 148ec1961344c47f7df75e905008138f91d9aa10 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:21:23 +0800 Subject: [PATCH 183/199] refactor: replace getVersionListById with getVersionList using GameComponentType in download providers --- .../org/jackhuang/hmcl/ui/main/MainPage.java | 2 +- .../download/AbstractDependencyManager.java | 9 +++++- .../hmcl/download/AutoDownloadProvider.java | 11 ++----- .../download/BMCLAPIDownloadProvider.java | 19 ------------ .../hmcl/download/DependencyManager.java | 8 +++++ .../hmcl/download/DownloadProvider.java | 7 ----- .../download/DownloadProviderWrapper.java | 30 ------------------- .../hmcl/download/MojangDownloadProvider.java | 19 ------------ .../game/GameInstanceJsonDownloadTask.java | 3 +- 9 files changed, 22 insertions(+), 86 deletions(-) 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 d8f290f51b7..136e66e2af2 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 @@ -361,7 +361,7 @@ private void launch() { private void launchNoGame() { DownloadProvider downloadProvider = DownloadProviders.getDownloadProvider(); - VersionList versionList = downloadProvider.getVersionListById("game"); + VersionList versionList = downloadProvider.getVersionList(GameComponentType.GAME); Holder instanceHolder = new Holder<>(); Task task = versionList.refreshAsync("") diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java index b70c5da399a..999b6ade46d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java @@ -17,6 +17,8 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; + /** * * @author huangyuhui @@ -30,6 +32,11 @@ public abstract class AbstractDependencyManager implements DependencyManager { @Override public VersionList getVersionList(String id) { - return getDownloadProvider().getVersionListById(id); + return getVersionList(GameComponentType.fromPatchId(id)); + } + + @Override + public VersionList getVersionList(GameComponentType componentType) { + return getDownloadProvider().getVersionList(componentType); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java index 7078dd9389c..45a4ae2a1be 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AutoDownloadProvider.java @@ -30,7 +30,7 @@ public final class AutoDownloadProvider implements DownloadProvider { private final List versionListProviders; private final List fileProviders; - private final ConcurrentMap> versionLists = new ConcurrentHashMap<>(); + private final ConcurrentMap> versionLists = new ConcurrentHashMap<>(); public AutoDownloadProvider( List versionListProviders, @@ -97,15 +97,10 @@ public List injectURLsWithCandidates(List urls) { @Override public VersionList getVersionList(GameComponentType componentType) { - return null; // TODO - } - - @Override - public VersionList getVersionListById(String id) { - return versionLists.computeIfAbsent(id, value -> { + return versionLists.computeIfAbsent(componentType, value -> { VersionList[] lists = new VersionList[versionListProviders.size()]; for (int i = 0; i < versionListProviders.size(); i++) { - lists[i] = versionListProviders.get(i).getVersionListById(value); + lists[i] = versionListProviders.get(i).getVersionList(value); } return new MultipleSourceVersionList(lists); }); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java index 39a31371e34..8c290ec8e68 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java @@ -139,25 +139,6 @@ public VersionList getVersionList(GameComponentType componentType) { }; } - @Override - public VersionList getVersionListById(String id) { - return switch (id) { - case "game" -> game; - case "fabric" -> fabric; - case "fabric-api" -> fabricApi; - case "forge" -> forge; - case "cleanroom" -> cleanroom; - case "neoforge" -> neoforge; - case "liteloader" -> liteLoader; - case "optifine" -> optifine; - case "quilt" -> quilt; - case "quilt-api" -> quiltApi; - case "legacyfabric" -> legacyFabric; - case "legacyfabric-api" -> legacyFabricApi; - default -> throw new IllegalArgumentException("Unrecognized version list id: " + id); - }; - } - private static String injectURL(List> replacement, String baseURL) { for (Pair pair : replacement) { if (baseURL.startsWith(pair.getKey())) { 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 318c9941fe6..7d069919f10 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java @@ -17,6 +17,7 @@ */ package org.jackhuang.hmcl.download; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstance; import org.jackhuang.hmcl.game.GameInstanceManifest; import org.jackhuang.hmcl.game.GameRepository; @@ -100,4 +101,11 @@ public interface DependencyManager { /// @return the registered version list /// @throws IllegalArgumentException if no list is registered for `id` VersionList getVersionList(String id); + + /// Returns a registered remote-version list. + /// + /// @param componentType the component type, such as `game`, `forge`, or `optifine` + /// @return the registered version list + /// @throws IllegalArgumentException if no list is registered for `id` + VersionList getVersionList(GameComponentType componentType); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java index 31588d432e9..e29a30f4ae2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProvider.java @@ -68,13 +68,6 @@ default List injectURLsWithCandidates(List urls) { /// @throws IllegalArgumentException if the version list does not exist VersionList getVersionList(GameComponentType componentType); - /// the specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" - /// - /// @param id the id of specific version list that this download provider provides. i.e. "fabric", "forge", "liteloader", "game", "optifine" - /// @return the version list - /// @throws IllegalArgumentException if the version list does not exist - VersionList getVersionListById(String id); - /// The maximum download concurrency that this download provider supports. /// /// @return the maximum download concurrency. 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 5f04a3ca7fb..45a74f47360 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DownloadProviderWrapper.java @@ -97,36 +97,6 @@ public Task refreshAsync(String gameVersion) { }; } - @Override - public VersionList getVersionListById(String id) { - - return new VersionList<>() { - @Override - public boolean hasType() { - return getProvider().getVersionListById(id).hasType(); - } - - @Override - public Task refreshAsync() { - throw new UnsupportedOperationException(); - } - - @Override - public Task refreshAsync(String gameVersion) { - return getProvider().getVersionListById(id).refreshAsync(gameVersion) - .thenComposeAsync(() -> { - lock.writeLock().lock(); - try { - versions.putAll(gameVersion, getProvider().getVersionListById(id).getVersions(gameVersion)); - } finally { - lock.writeLock().unlock(); - } - return null; - }); - } - }; - } - @Override public int getConcurrency() { return getProvider().getConcurrency(); 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 e9d2dc210a9..60119e80da9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java @@ -100,25 +100,6 @@ public VersionList getVersionList(GameComponentType componentType) { }; } - @Override - public VersionList getVersionListById(String id) { - return switch (id) { - case "game" -> game; - case "fabric" -> fabric; - case "fabric-api" -> fabricApi; - case "forge" -> forge; - case "cleanroom" -> cleanroom; - case "neoforge" -> neoforge; - case "liteloader" -> liteLoader; - case "optifine" -> optifine; - case "quilt" -> quilt; - case "quilt-api" -> quiltApi; - case "legacyfabric" -> legacyFabric; - case "legacyfabric-api" -> legacyFabricApi; - default -> throw new IllegalArgumentException("Unrecognized version list id: " + id); - }; - } - @Override public String injectURL(String baseURL) { return baseURL; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstanceJsonDownloadTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstanceJsonDownloadTask.java index 046159feda0..d3c37a8d66e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstanceJsonDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstanceJsonDownloadTask.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.RemoteVersion; import org.jackhuang.hmcl.download.VersionList; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.task.GetTask; import org.jackhuang.hmcl.task.Task; @@ -42,7 +43,7 @@ public final class GameInstanceJsonDownloadTask extends Task { public GameInstanceJsonDownloadTask(String gameVersion, DefaultDependencyManager dependencyManager) { this.gameVersion = gameVersion; this.dependencyManager = dependencyManager; - this.gameVersionList = dependencyManager.getVersionList("game"); + this.gameVersionList = dependencyManager.getVersionList(GameComponentType.GAME); dependents.add(gameVersionList.loadAsync(gameVersion)); From 028bd80acd16e6ae713e239dcdad2726fdad6008 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:22:48 +0800 Subject: [PATCH 184/199] refactor: remove deprecated installComponentAsync methods from DependencyManager and DefaultDependencyManager --- .../download/DefaultDependencyManager.java | 21 ------------------- .../hmcl/download/DependencyManager.java | 20 ------------------ 2 files changed, 41 deletions(-) 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 c84f415b62a..8708d50b0c6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -321,27 +321,6 @@ public Task installComponentAsync( .withStage(String.format("hmcl.install.%s:%s", libraryId, libraryVersion)); } - @Override - public Task installComponentAsync( - String gameVersion, - GameInstanceManifest baseManifest, - String libraryId, - String libraryVersion) { - return installComponentAsync( - repository.getInstance(baseManifest.id()), - baseManifest, - gameVersion, - libraryId, - libraryVersion); - } - - @Override - public Task installComponentAsync( - GameInstanceManifest baseVersion, - RemoteVersion libraryVersion) { - return installComponentAsync(repository.getInstance(baseVersion.id()), baseVersion, libraryVersion); - } - /// Installs a component from a local installer jar into a registered instance. /// /// @param instance the target instance 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 7d069919f10..84fca97e36c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java @@ -75,26 +75,6 @@ public interface DependencyManager { /// @return a new game builder GameBuilder newGameBuilder(); - /// Creates a task that installs a loader or patch into a registered instance's working manifest. - /// - /// The instance must already be saved in [#getGameRepository()] so install tasks can resolve - /// run/mods directories. Prefer instance-bound overloads on concrete managers when available. - /// - /// @param gameVersion the Minecraft version required by the library - /// @param baseVersion the working manifest for this step (same id as the registered instance) - /// @param libraryId the registered library type, such as `forge` or `optifine` - /// @param libraryVersion the library version to install - /// @return the installation task - Task installComponentAsync(String gameVersion, GameInstanceManifest baseVersion, String libraryId, String libraryVersion); - - /// Creates a task that installs a remote loader or patch into a registered instance's working - /// manifest. - /// - /// @param baseVersion the working manifest for this step (same id as the registered instance) - /// @param libraryVersion the remote library version to install - /// @return the installation task - Task installComponentAsync(GameInstanceManifest baseVersion, RemoteVersion libraryVersion); - /// Returns a registered remote-version list. /// /// @param id the list identifier, such as `game`, `forge`, or `optifine` From e8ff3d1c1d3c73727719248a81af5414a846cd64 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:23:46 +0800 Subject: [PATCH 185/199] refactor: remove installComponentAsync method from DefaultDependencyManager --- .../hmcl/download/DefaultDependencyManager.java | 10 ---------- 1 file changed, 10 deletions(-) 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 8708d50b0c6..a8902c9cd64 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -174,16 +174,6 @@ public Task checkPatchCompletionAsync( }); } - /// Installs a component into a registered instance using its stored manifest as the base. - /// - /// @param instance the target instance; must belong to this manager's repository - /// @param libraryVersion the remote component to install - /// @return the task producing the updated standalone manifest (not yet saved) - public Task installComponentAsync(GameInstance instance, RemoteVersion libraryVersion) { - validateGameInstance(instance); - return installComponentAsync(instance, instance.getManifest(), libraryVersion); - } - /// 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` From 0ee2dc3f920f13c0c6042fd74d386fb44056024e Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:28:52 +0800 Subject: [PATCH 186/199] refactor: update install methods to use GameComponentType instead of string identifiers --- .../hmcl/game/HMCLModpackInstallTask.java | 2 +- .../download/AbstractDependencyManager.java | 5 --- .../download/DefaultDependencyManager.java | 43 +++++++++---------- .../hmcl/download/DefaultGameBuilder.java | 4 +- .../hmcl/download/DependencyManager.java | 7 --- 5 files changed, 24 insertions(+), 37 deletions(-) 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 31372d870ae..2e2113b8eda 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -107,7 +107,7 @@ public void execute() throws Exception { publishedInstance, manifest, modpack.getGameVersion(), - mark.componentType().getPatchId(), + mark.componentType(), componentVersion)); } return libraryTask; 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 999b6ade46d..beec622e2b2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/AbstractDependencyManager.java @@ -30,11 +30,6 @@ public abstract class AbstractDependencyManager implements DependencyManager { @Override public abstract DefaultCacheRepository getCacheRepository(); - @Override - public VersionList getVersionList(String id) { - return getVersionList(GameComponentType.fromPatchId(id)); - } - @Override public VersionList getVersionList(GameComponentType componentType) { return getDownloadProvider().getVersionList(componentType); 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 a8902c9cd64..b7b5c6ca92b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -158,7 +158,7 @@ public Task checkPatchCompletionAsync( 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, "optifine", optifinePatchVersion)); + tasks.add(installComponentAsync(instance, original, gameVersion, GameComponentType.OPTIFINE, optifinePatchVersion)); } else { tasks.add(OptiFineInstallTask.install( this, @@ -235,32 +235,32 @@ Task installNewInstanceComponentAsync( /// Resolves and installs a component into an unpublished new instance. /// - /// @param instanceId the unpublished instance id - /// @param baseManifest the working manifest for this step - /// @param gameVersion the Minecraft version used to look up the remote list - /// @param libraryId the component list id, such as `game` or `forge` - /// @param libraryVersion the component version id + /// @param instanceId the unpublished instance id + /// @param baseManifest the working manifest for this step + /// @param gameVersion the Minecraft version used to look up the remote list + /// @param componentType the component list id, such as `game` or `forge` + /// @param componentVersion the component version id /// @return the installation task Task installNewInstanceComponentAsync( GameInstanceID instanceId, GameInstanceManifest baseManifest, String gameVersion, - String libraryId, - String libraryVersion) { + GameComponentType componentType, + String componentVersion) { if (!instanceId.equals(baseManifest.id())) { throw new IllegalArgumentException("baseManifest id does not match instanceId"); } - VersionList versionList = getVersionList(libraryId); + VersionList versionList = getVersionList(componentType); return versionList.loadAsync(gameVersion) .thenComposeAsync(() -> installNewInstanceComponentAsync( instanceId, baseManifest, gameVersion, - versionList.getVersion(gameVersion, libraryVersion) + versionList.getVersion(gameVersion, componentVersion) .orElseThrow(() -> new IOException( - "Remote library " + libraryId + " has no version " + libraryVersion)))) - .withStage(String.format("hmcl.install.%s:%s", libraryId, libraryVersion)); + "Remote library " + componentType + " has no version " + componentVersion)))) + .withStage(String.format("hmcl.install.%s:%s", componentType, componentVersion)); } /// Removes one component from an unpublished new instance manifest. @@ -286,29 +286,29 @@ private Task removeNewInstanceComponentAsync( /// @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 libraryId the component list id, such as `game` or `forge` - /// @param libraryVersion the component version id + /// @param componentType the component list id, such as `game` or `forge` + /// @param componentVersion the component version id /// @return the installation task public Task installComponentAsync( GameInstance instance, GameInstanceManifest baseManifest, String gameVersion, - String libraryId, - String libraryVersion) { + GameComponentType componentType, + String componentVersion) { validateGameInstance(instance); if (!instance.getId().equals(baseManifest.id())) { throw new IllegalArgumentException("baseManifest id does not match instance"); } - VersionList versionList = getVersionList(libraryId); + VersionList versionList = getVersionList(componentType); return versionList.loadAsync(gameVersion) .thenComposeAsync(() -> installComponentAsync( instance, baseManifest, - versionList.getVersion(gameVersion, libraryVersion) + versionList.getVersion(gameVersion, componentVersion) .orElseThrow(() -> new IOException( - "Remote library " + libraryId + " has no version " + libraryVersion)))) - .withStage(String.format("hmcl.install.%s:%s", libraryId, libraryVersion)); + "Remote library " + componentType + " has no version " + componentVersion)))) + .withStage(String.format("hmcl.install.%s:%s", componentType, componentVersion)); } /// Installs a component from a local installer jar into a registered instance. @@ -337,8 +337,7 @@ public Task installComponentAsync( } String gameVersion = instance.getVersion().toString(); - return Task - .composeAsync(() -> { + return Task.composeAsync(() -> { try { return CleanroomInstallTask.install(this, baseManifest, gameVersion, installer); } catch (IOException ignore) { 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 1420a6e5b59..e6f7dc86ec3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -82,11 +82,11 @@ public Task buildAsync() { GameRepositoryDraft draft = repository.openDraft(); Task libraryTask = dependencyManager.installNewInstanceComponentAsync( - id, new GameInstanceManifest(id), gameVersion, GameComponentType.GAME.getPatchId(), gameVersion); + id, new GameInstanceManifest(id), gameVersion, GameComponentType.GAME, gameVersion); for (Map.Entry entry : toolVersions.entrySet()) { libraryTask = libraryTask.thenComposeAsync(manifest -> dependencyManager.installNewInstanceComponentAsync( - id, manifest, gameVersion, entry.getKey(), entry.getValue())); + id, manifest, gameVersion, GameComponentType.fromPatchId(entry.getKey()), entry.getValue())); } for (RemoteVersion remoteVersion : remoteVersions) { 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 84fca97e36c..c2a4890d284 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DependencyManager.java @@ -75,13 +75,6 @@ public interface DependencyManager { /// @return a new game builder GameBuilder newGameBuilder(); - /// Returns a registered remote-version list. - /// - /// @param id the list identifier, such as `game`, `forge`, or `optifine` - /// @return the registered version list - /// @throws IllegalArgumentException if no list is registered for `id` - VersionList getVersionList(String id); - /// Returns a registered remote-version list. /// /// @param componentType the component type, such as `game`, `forge`, or `optifine` From d80df3087fe08d926ff3de140ea2b65d8880fb18 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:50:01 +0800 Subject: [PATCH 187/199] refactor: update GameBuilder to use GameComponentType for component management --- .../hmcl/ui/download/DownloadPage.java | 2 +- .../UpdateInstallerWizardProvider.java | 2 +- .../hmcl/download/DefaultGameBuilder.java | 49 +++++++++++-------- .../jackhuang/hmcl/download/GameBuilder.java | 30 +++++------- .../hmcl/modpack/curse/CurseInstallTask.java | 7 +-- .../mcbbs/McbbsModpackLocalInstallTask.java | 10 ++-- .../modpack/modrinth/ModrinthInstallTask.java | 9 ++-- .../server/ServerModpackCompletionTask.java | 5 +- .../server/ServerModpackLocalInstallTask.java | 6 ++- .../ServerModpackRemoteInstallTask.java | 6 ++- 10 files changed, 70 insertions(+), 56 deletions(-) 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 4b15a2634cd..439f8761dc5 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 @@ -316,7 +316,7 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { settings.asStringMap().forEach((key, value) -> { if (!GameComponentType.GAME.getPatchId().equals(key) && value instanceof RemoteVersion remoteVersion) - builder.version(remoteVersion); + builder.component(remoteVersion); }); repository.applyDefaultIsolationSettingForNewInstance(instanceId, settings.isInstallingModdedVersion()); 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 e6489fc778d..8372e6de35a 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 @@ -75,7 +75,7 @@ public Object finish(SettingsMap settings) { for (Object value : settings.asStringMap().values()) { if (value instanceof RemoteVersion remoteVersion) { hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()))); - if ("game".equals(remoteVersion.getLibraryId())) { + if (remoteVersion.getComponentType() == GameComponentType.GAME) { hints.add(new Task.StagesHint("hmcl.install.libraries")); hints.add(new Task.StagesHint("hmcl.install.assets")); } 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 e6f7dc86ec3..234af6a7761 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -61,21 +61,25 @@ public DefaultDependencyManager getDependencyManager() { @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(); - hints.add(new Task.StagesHint("hmcl.install.game:" + gameVersion)); - hints.add(new Task.StagesHint("hmcl.install.libraries")); - hints.add(new Task.StagesHint("hmcl.install.assets")); - for (Map.Entry entry : toolVersions.entrySet()) { + components.forEach((componentType, version) -> { hints.add(new Task.StagesHint( - String.format("hmcl.install.%s:%s", entry.getKey(), entry.getValue()))); - } - for (RemoteVersion remoteVersion : remoteVersions) { - hints.add(new Task.StagesHint(String.format( - "hmcl.install.%s:%s", - remoteVersion.getLibraryId(), - remoteVersion.getSelfVersion()))); - } + 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 @@ -84,15 +88,20 @@ public Task buildAsync() { Task libraryTask = dependencyManager.installNewInstanceComponentAsync( id, new GameInstanceManifest(id), gameVersion, GameComponentType.GAME, gameVersion); - for (Map.Entry entry : toolVersions.entrySet()) { - libraryTask = libraryTask.thenComposeAsync(manifest -> dependencyManager.installNewInstanceComponentAsync( - id, manifest, gameVersion, GameComponentType.fromPatchId(entry.getKey()), entry.getValue())); - } + for (Map.Entry entry : components.entrySet()) { + GameComponentType componentType = entry.getKey(); - for (RemoteVersion remoteVersion : remoteVersions) { - libraryTask = libraryTask.thenComposeAsync(manifest -> - dependencyManager.installNewInstanceComponentAsync( - id, manifest, gameVersion, remoteVersion)); + 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()); + } } return libraryTask.thenComposeAsync(manifest -> 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 f8d517edadd..faf199e0c8d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java @@ -17,8 +17,10 @@ */ 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.Nullable; import java.util.*; @@ -29,13 +31,7 @@ public abstract class GameBuilder { protected @Nullable GameInstanceID id; - protected String gameVersion = ""; - protected final Map toolVersions = new HashMap<>(); - protected final Set remoteVersions = new HashSet<>(); - - public GameInstanceID getId() { - return id; - } + protected final Map components = new EnumMap<>(GameComponentType.class); /// The new game version name, for `.minecraft/`. /// @@ -45,25 +41,21 @@ public GameBuilder id(GameInstanceID id) { return this; } + @Contract("_ -> this") public GameBuilder gameVersion(String version) { - this.gameVersion = Objects.requireNonNull(version); + components.put(GameComponentType.GAME, version); return this; } - /** - * @param id the core library id. i.e. "forge", "liteloader", "optifine" - * @param version the version of the core library. For documents, you can first try [VersionList.versions] - */ - public GameBuilder version(String id, String version) { - if ("game".equals(id)) - gameVersion(version); - else - toolVersions.put(id, version); + @Contract("_, _ -> this") + public GameBuilder component(GameComponentType componentType, String version) { + components.put(componentType, version); return this; } - public GameBuilder version(RemoteVersion remoteVersion) { - remoteVersions.add(remoteVersion); + @Contract("_ -> this") + public GameBuilder component(RemoteVersion remoteVersion) { + components.put(remoteVersion.getComponentType(), remoteVersion); return this; } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java index 9246a7b7cc5..7130c140f11 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java @@ -21,6 +21,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.*; import org.jackhuang.hmcl.task.CacheFileTask; @@ -86,11 +87,11 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId).gameVersion(manifest.minecraft().gameVersion()); for (CurseManifestModLoader modLoader : manifest.minecraft().modLoaders()) { if (modLoader.id().startsWith("forge-")) { - builder.version("forge", modLoader.id().substring("forge-".length())); + builder.component(GameComponentType.FORGE, modLoader.id().substring("forge-".length())); } else if (modLoader.id().startsWith("fabric-")) { - builder.version("fabric", modLoader.id().substring("fabric-".length())); + builder.component(GameComponentType.FABRIC, modLoader.id().substring("fabric-".length())); } else if (modLoader.id().startsWith("neoforge-")) { - builder.version("neoforge", modLoader.id().substring("neoforge-".length())); + builder.component(GameComponentType.NEO_FORGE, modLoader.id().substring("neoforge-".length())); } } dependents.add(builder.buildAsync()); 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 fb275a9d76d..c2a801815c9 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackLocalInstallTask.java @@ -20,16 +20,14 @@ import com.google.gson.JsonParseException; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; -import org.jackhuang.hmcl.game.DefaultGameRepository; -import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.game.GameInstancePatch; +import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.modpack.MinecraftInstanceTask; import org.jackhuang.hmcl.modpack.Modpack; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.modpack.ModpackInstallTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -69,7 +67,9 @@ public McbbsModpackLocalInstallTask(DefaultDependencyManager dependencyManager, GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); for (McbbsModpackManifest.Addon addon : manifest.getAddons()) { - builder.version(addon.getId(), addon.getVersion()); + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); } dependents.add(builder.buildAsync()); 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 36baff92bee..04b196b97cf 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java @@ -21,6 +21,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.*; import org.jackhuang.hmcl.task.CacheFileTask; @@ -74,18 +75,18 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF case "minecraft": break; case "forge": - builder.version("forge", modLoader.getValue()); + builder.component(GameComponentType.FORGE, modLoader.getValue()); break; case "neoforge": // https://github.com/HMCL-dev/HMCL/pull/5170 case "neo-forge": - builder.version("neoforge", modLoader.getValue()); + builder.component(GameComponentType.NEO_FORGE, modLoader.getValue()); break; case "fabric-loader": - builder.version("fabric", modLoader.getValue()); + builder.component(GameComponentType.FABRIC, modLoader.getValue()); break; case "quilt-loader": - builder.version("quilt", modLoader.getValue()); + builder.component(GameComponentType.QUILT, modLoader.getValue()); break; default: throw new IllegalStateException("Unsupported mod loader " + modLoader.getKey()); 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 6e09629d747..84a50b826fe 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackCompletionTask.java @@ -22,6 +22,7 @@ import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.addon.LocalAddonManager; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.GetTask; @@ -143,7 +144,9 @@ public void execute() throws Exception { if (!Objects.equals(oldAddons, newAddons)) { GameBuilder builder = dependencyManager.newGameBuilder().id(instance.getId()); for (ServerModpackManifest.Addon addon : remoteManifest.getAddons()) { - builder.version(addon.getId(), addon.getVersion()); + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); } dependencies.add(builder.buildAsync()); 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 21e1a9bcf95..57c3361ebdd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackLocalInstallTask.java @@ -21,6 +21,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.MinecraftInstanceTask; import org.jackhuang.hmcl.modpack.Modpack; @@ -28,6 +29,7 @@ import org.jackhuang.hmcl.modpack.ModpackInstallTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -60,7 +62,9 @@ public ServerModpackLocalInstallTask(DefaultDependencyManager dependencyManager, GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); for (ServerModpackManifest.Addon addon : manifest.getAddons()) { - builder.version(addon.getId(), addon.getVersion()); + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); } dependents.add(builder.buildAsync()); 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 1180073e790..1f8394dedd6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackRemoteInstallTask.java @@ -21,10 +21,12 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.game.DefaultGameRepository; +import org.jackhuang.hmcl.game.GameComponentType; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.modpack.ModpackConfiguration; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -54,7 +56,9 @@ public ServerModpackRemoteInstallTask(DefaultDependencyManager dependencyManager GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); for (ServerModpackManifest.Addon addon : manifest.getAddons()) { - builder.version(addon.getId(), addon.getVersion()); + @Nullable GameComponentType componentType = GameComponentType.fromPatchId(addon.getId()); + if (componentType != null) + builder.component(componentType, addon.getVersion()); } dependents.add(builder.buildAsync()); From c1a763a9870862070f6de09637f3968398e787be Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:54:02 +0800 Subject: [PATCH 188/199] refactor: update GameBuilder usage to set components with GameComponentType --- .../org/jackhuang/hmcl/game/HMCLModpackInstallTask.java | 3 ++- .../java/org/jackhuang/hmcl/ui/download/DownloadPage.java | 2 +- HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java | 3 ++- .../main/java/org/jackhuang/hmcl/download/GameBuilder.java | 6 ------ .../org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java | 2 +- .../hmcl/modpack/modrinth/ModrinthInstallTask.java | 3 ++- 6 files changed, 8 insertions(+), 11 deletions(-) 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 2e2113b8eda..9f580f1819e 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackInstallTask.java @@ -19,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; @@ -55,7 +56,7 @@ public HMCLModpackInstallTask(HMCLGameRepository repository, Path zipFile, Modpa if (repository.hasInstance(this.instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists"); - dependents.add(dependency.newGameBuilder().id(this.instanceId).gameVersion(modpack.getGameVersion()).buildAsync()); + dependents.add(dependency.newGameBuilder().id(this.instanceId).component(GameComponentType.GAME, modpack.getGameVersion()).buildAsync()); onDone().register(event -> { if (event.isFailed()) repository.removeInstanceFromDisk(this.instanceId); 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 439f8761dc5..7dd4eb2ac89 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -311,7 +311,7 @@ private Task finishVersionDownloadingAsync(SettingsMap settings) { GameInstanceID instanceId = settings.get(AbstractInstallersPage.INSTANCE_ID); builder.id(instanceId); - builder.gameVersion(((RemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion()); + builder.component(GameComponentType.GAME, ((RemoteVersion) settings.get(GameComponentType.GAME.getPatchId())).getGameVersion()); settings.asStringMap().forEach((key, value) -> { if (!GameComponentType.GAME.getPatchId().equals(key) 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 136e66e2af2..0aa0c872aae 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/main/MainPage.java @@ -46,6 +46,7 @@ import org.jackhuang.hmcl.Metadata; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.download.GameBuilder; import org.jackhuang.hmcl.download.VersionList; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.setting.DownloadProviders; @@ -382,7 +383,7 @@ private void launchNoGame() { return dependency.newGameBuilder() .id(instanceId) - .gameVersion(gameVersion) + .component(GameComponentType.GAME, gameVersion) .buildAsync(); }) .whenComplete(any -> GameDirectoryManager.getSelectedRepository().refresh()) 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 faf199e0c8d..706fab1f763 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java @@ -41,12 +41,6 @@ public GameBuilder id(GameInstanceID id) { return this; } - @Contract("_ -> this") - public GameBuilder gameVersion(String version) { - components.put(GameComponentType.GAME, version); - return this; - } - @Contract("_, _ -> this") public GameBuilder component(GameComponentType componentType, String version) { components.put(componentType, version); 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 7130c140f11..6d649e4f482 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java @@ -84,7 +84,7 @@ public CurseInstallTask(DefaultDependencyManager dependencyManager, Path zipFile if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); - GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId).gameVersion(manifest.minecraft().gameVersion()); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId).component(GameComponentType.GAME, manifest.minecraft().gameVersion()); for (CurseManifestModLoader modLoader : manifest.minecraft().modLoaders()) { if (modLoader.id().startsWith("forge-")) { builder.component(GameComponentType.FORGE, modLoader.id().substring("forge-".length())); 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 04b196b97cf..ab579ff1b9f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java @@ -69,7 +69,8 @@ public ModrinthInstallTask(DefaultDependencyManager dependencyManager, Path zipF if (repository.hasInstance(instanceId) && Files.notExists(json)) throw new IllegalArgumentException("Instance " + instanceId + " already exists."); - GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId).gameVersion(manifest.getGameVersion()); + GameBuilder builder = dependencyManager.newGameBuilder().id(instanceId); + builder.component(GameComponentType.GAME, manifest.getGameVersion()); for (Map.Entry modLoader : manifest.getDependencies().entrySet()) { switch (modLoader.getKey()) { case "minecraft": From e01ad09b4bcc1e93b3ef63b38384fa238a80d26d Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:55:32 +0800 Subject: [PATCH 189/199] refactor: add @NotNullByDefault annotation to GameBuilder class --- .../src/main/java/org/jackhuang/hmcl/download/GameBuilder.java | 2 ++ 1 file changed, 2 insertions(+) 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 706fab1f763..e911fc85bf2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java @@ -21,6 +21,7 @@ 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.*; @@ -28,6 +29,7 @@ /// The builder which provide a task to build Minecraft environment. /// /// @author huangyuhui +@NotNullByDefault public abstract class GameBuilder { protected @Nullable GameInstanceID id; From fbcafcd5636bbdcc46e2d51af448a11ae9546918 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:57:41 +0800 Subject: [PATCH 190/199] refactor: update DefaultDependencyManager and related classes to use componentVersion instead of libraryVersion --- .../download/UpdateInstallerWizardProvider.java | 2 +- .../hmcl/download/DefaultDependencyManager.java | 16 ++++++++-------- .../org/jackhuang/hmcl/download/GameBuilder.java | 4 ++-- .../jackhuang/hmcl/download/RemoteVersion.java | 6 ------ 4 files changed, 11 insertions(+), 17 deletions(-) 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 8372e6de35a..53a29094c03 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/UpdateInstallerWizardProvider.java @@ -74,7 +74,7 @@ public Object finish(SettingsMap settings) { var hints = new ArrayList(); for (Object value : settings.asStringMap().values()) { if (value instanceof RemoteVersion remoteVersion) { - hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getLibraryId(), remoteVersion.getSelfVersion()))); + hints.add(new Task.StagesHint(String.format("hmcl.install.%s:%s", remoteVersion.getComponentType().getPatchId(), remoteVersion.getSelfVersion()))); if (remoteVersion.getComponentType() == GameComponentType.GAME) { hints.add(new Task.StagesHint("hmcl.install.libraries")); hints.add(new Task.StagesHint("hmcl.install.assets")); 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 b7b5c6ca92b..b0c928a6ef3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -199,7 +199,7 @@ public Task installComponentAsync( .thenComposeAsync(manifest -> libraryVersion .getInstallTask(this, manifest, modsDirectory) .thenApplyAsync(patch -> patch == null ? manifest : manifest.addPatch(patch))) - .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getLibraryId(), libraryVersion.getSelfVersion())); + .withStage(String.format("hmcl.install.%s:%s", libraryVersion.getComponentType().getPatchId(), libraryVersion.getSelfVersion())); } /// Installs a component into an unpublished new instance without constructing a @@ -208,13 +208,13 @@ public Task installComponentAsync( /// @param instanceId the unpublished instance id /// @param baseManifest the working manifest for this step /// @param gameVersion the Minecraft version used for component analysis - /// @param libraryVersion the remote component to install + /// @param componentVersion the remote component to install /// @return the task producing the updated manifest (not yet committed) Task installNewInstanceComponentAsync( GameInstanceID instanceId, GameInstanceManifest baseManifest, String gameVersion, - RemoteVersion libraryVersion) { + RemoteVersion componentVersion) { if (!instanceId.equals(baseManifest.id())) { throw new IllegalArgumentException("baseManifest id does not match instanceId"); } @@ -223,14 +223,14 @@ Task installNewInstanceComponentAsync( return removeNewInstanceComponentAsync( baseManifest, GameVersionNumber.asGameVersion(gameVersion), - libraryVersion.getComponentType()) - .thenComposeAsync(manifest -> libraryVersion + componentVersion.getComponentType()) + .thenComposeAsync(manifest -> componentVersion .getInstallTask(this, manifest, modsDirectory) .thenApplyAsync(patch -> patch == null ? manifest : manifest.addPatch(patch))) .withStage(String.format( "hmcl.install.%s:%s", - libraryVersion.getLibraryId(), - libraryVersion.getSelfVersion())); + componentVersion.getComponentType().getPatchId(), + componentVersion.getSelfVersion())); } /// Resolves and installs a component into an unpublished new instance. @@ -259,7 +259,7 @@ Task installNewInstanceComponentAsync( gameVersion, versionList.getVersion(gameVersion, componentVersion) .orElseThrow(() -> new IOException( - "Remote library " + componentType + " has no version " + componentVersion)))) + "Remote component " + componentType + " has no version " + componentVersion)))) .withStage(String.format("hmcl.install.%s:%s", componentType, componentVersion)); } 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 e911fc85bf2..698ffa74ecb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java @@ -35,9 +35,9 @@ public abstract class GameBuilder { protected @Nullable GameInstanceID id; protected final Map components = new EnumMap<>(GameComponentType.class); - /// The new game version name, for `.minecraft/`. + /// The new game instance id, for `.minecraft/`. /// - /// @param id the name of new game version. + /// @param id the instance id of new game instance. public GameBuilder id(GameInstanceID id) { this.id = Objects.requireNonNull(id); return this; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java index 5c38a5a4c5b..6c71b808624 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/RemoteVersion.java @@ -37,7 +37,6 @@ public class RemoteVersion implements Comparable { private final GameComponentType componentType; - private final String libraryId; private final String gameVersion; private final String selfVersion; private final Instant releaseDate; @@ -64,7 +63,6 @@ public RemoteVersion(GameComponentType componentType, String gameVersion, String */ public RemoteVersion(GameComponentType componentType, String gameVersion, String selfVersion, Instant releaseDate, Type type, List urls) { this.componentType = Objects.requireNonNull(componentType); - this.libraryId = componentType.getPatchId(); this.gameVersion = Objects.requireNonNull(gameVersion); this.selfVersion = Objects.requireNonNull(selfVersion); this.releaseDate = releaseDate; @@ -76,10 +74,6 @@ public GameComponentType getComponentType() { return componentType; } - public String getLibraryId() { - return getComponentType().getPatchId(); - } - public String getGameVersion() { return gameVersion; } From 48fc8e5d51e5d1488eb492a7f4552346c8c90a1a Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 21:59:15 +0800 Subject: [PATCH 191/199] refactor: change components map to use EnumMap for GameComponentType --- .../src/main/java/org/jackhuang/hmcl/download/GameBuilder.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 698ffa74ecb..6b35aeb9645 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/GameBuilder.java @@ -33,7 +33,7 @@ public abstract class GameBuilder { protected @Nullable GameInstanceID id; - protected final Map components = new EnumMap<>(GameComponentType.class); + protected final EnumMap components = new EnumMap<>(GameComponentType.class); /// The new game instance id, for `.minecraft/`. /// From 5bc1661aaf8b2d3525fe213e463896ef495ca46a Mon Sep 17 00:00:00 2001 From: Glavo Date: Sat, 15 Aug 2026 22:04:31 +0800 Subject: [PATCH 192/199] refactor: remove BOOTSTRAP_LAUNCHER case and update related methods for bootstrap version handling --- .../hmcl/download/BMCLAPIDownloadProvider.java | 1 - .../hmcl/download/MojangDownloadProvider.java | 1 - .../hmcl/game/GameComponentAnalyzer.java | 15 +++++++++++++-- .../jackhuang/hmcl/game/GameComponentType.java | 7 +------ .../hmcl/game/LaunchManifestNormalizer.java | 2 +- .../jackhuang/hmcl/launch/DefaultLauncher.java | 2 +- 6 files changed, 16 insertions(+), 12 deletions(-) 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 8c290ec8e68..8db94c8d1b6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/BMCLAPIDownloadProvider.java @@ -135,7 +135,6 @@ public VersionList getVersionList(GameComponentType componentType) { case QUILT_API -> quiltApi; case LEGACY_FABRIC -> legacyFabric; case LEGACY_FABRIC_API -> legacyFabricApi; - case BOOTSTRAP_LAUNCHER -> throw new IllegalArgumentException("Unrecognized component: " + componentType); }; } 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 60119e80da9..a2ecd9955a8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/MojangDownloadProvider.java @@ -96,7 +96,6 @@ public VersionList getVersionList(GameComponentType componentType) { case QUILT_API -> quiltApi; case LEGACY_FABRIC -> legacyFabric; case LEGACY_FABRIC_API -> legacyFabricApi; - case BOOTSTRAP_LAUNCHER -> throw new IllegalArgumentException("Unrecognized component: " + componentType); }; } 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 a45f8b24775..2aa2ba16d23 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentAnalyzer.java @@ -35,6 +35,7 @@ private static GameComponentAnalyzer analyze( GameInstanceManifest launchManifest, @Nullable GameVersionNumber gameVersion) { var components = new EnumMap(GameComponentType.class); + @Nullable String bootstrapVersion = null; if (gameVersion != null && !gameVersion.equals(GameVersionNumber.unknown())) { components.put(GameComponentType.GAME, new Mark(GameComponentType.GAME, gameVersion.toString(), true)); @@ -59,9 +60,13 @@ private static GameComponentAnalyzer analyze( break; } } + + if (bootstrapVersion == null && library.is("cpw.mods", "bootstraplauncher")) { + bootstrapVersion = library.version(); + } } - return new GameComponentAnalyzer(standaloneManifest, components); + return new GameComponentAnalyzer(standaloneManifest, components, bootstrapVersion); } public static GameComponentAnalyzer analyze(GameInstanceManifest.Resolved resolved, @Nullable GameVersionNumber gameVersion) { @@ -77,10 +82,12 @@ public static GameComponentAnalyzer analyze(GameInstanceManifest manifest, @Null private final GameInstanceManifest manifest; private final @Unmodifiable Map components; + private final @Nullable String bootstrapVersion; - private GameComponentAnalyzer(GameInstanceManifest manifest, @Unmodifiable Map components) { + private GameComponentAnalyzer(GameInstanceManifest manifest, @Unmodifiable Map components, @Nullable String bootstrapVersion) { this.manifest = manifest; this.components = components; + this.bootstrapVersion = bootstrapVersion; } public boolean has(GameComponentType type) { @@ -146,6 +153,10 @@ public GameInstanceManifest removeLibrary(GameComponentType componentType) { return mark != null ? mark.version() : null; } + public @Nullable String getBootstrapVersion() { + return bootstrapVersion; + } + /// If a library is provided in `$.patches`, it's structure is so clear that we can do any operation. /// Otherwise, we must guess how are these libraries mixed. /// Maybe a guessing implementation will be provided in the future. But by now, we simply set it to JUST\_EXISTED. diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java index 0d3a21e792a..74db17271af 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -218,12 +218,7 @@ protected boolean matchLibrary(Library library, List libraries) { return "org.quiltmc".equals(library.groupId()) && "quilt-api".equals(library.artifactId()); } }, - BOOTSTRAP_LAUNCHER("") { - @Override - protected boolean matchLibrary(Library library, List libraries) { - return "cpw.mods".equals(library.groupId()) && "bootstraplauncher".equals(library.artifactId()); - } - }; + ; public static final List ALL = List.of(GameComponentType.values()); public static final List MOD_LOADERS = ALL.stream() 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 4f4763a2dd0..05501087650 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/LaunchManifestNormalizer.java @@ -191,7 +191,7 @@ private static GameInstanceManifest repairBootstrapLauncher( return manifest; } - if (Optional.ofNullable(analyzer.getVersion(GameComponentType.BOOTSTRAP_LAUNCHER)) + if (Optional.ofNullable(analyzer.getBootstrapVersion()) .filter(version -> VersionNumber.compare(version, "0.1.17") >= 0) .isEmpty()) { return manifest; 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 f1b2bf34a22..477067572f3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/launch/DefaultLauncher.java @@ -548,7 +548,7 @@ private List rewriteUnsafeBootstrapLauncherIgnoreList( if (!instance.hasComponent(GameComponentType.FORGE) && !instance.hasComponent(GameComponentType.NEO_FORGE)) { return jvmArguments; } - @Nullable String bootstrapVersion = instance.getAnalyzer().getVersion(GameComponentType.BOOTSTRAP_LAUNCHER); + @Nullable String bootstrapVersion = instance.getAnalyzer().getBootstrapVersion(); if (bootstrapVersion == null || VersionNumber.compare(bootstrapVersion, "0.1.17") >= 0) { return jvmArguments; } From a573c323ab8c6d8ec8960494926217f1d4a92dde Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 16 Aug 2026 20:47:05 +0800 Subject: [PATCH 193/199] refactor: simplify library matching logic in GameComponentType using helper method --- .../jackhuang/hmcl/game/GameComponentType.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java index 74db17271af..a29ab86ce96 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameComponentType.java @@ -43,7 +43,7 @@ protected boolean matchLibrary(Library library, List libraries) { LEGACY_FABRIC("legacyfabric", ModLoaderType.LEGACY_FABRIC) { @Override protected boolean matchLibrary(Library library, List libraries) { - if ("net.fabricmc".equals(library.groupId()) && "fabric-loader".equals(library.artifactId())) { + if (library.is("net.fabricmc", "fabric-loader")) { for (Library l : libraries) { if ("net.legacyfabric".equals(l.groupId())) { return true; @@ -56,13 +56,13 @@ protected boolean matchLibrary(Library library, List libraries) { LEGACY_FABRIC_API("legacyfabric-api") { @Override protected boolean matchLibrary(Library library, List libraries) { - return "net.legacyfabric".equals(library.groupId()) && "legacyfabric-api".equals(library.artifactId()); + return library.is("net.legacyfabric", "legacyfabric-api"); } }, FABRIC("fabric", ModLoaderType.FABRIC) { @Override protected boolean matchLibrary(Library library, List libraries) { - if ("net.fabricmc".equals(library.groupId()) && "fabric-loader".equals(library.artifactId())) { + if (library.is("net.fabricmc", "fabric-loader")) { for (Library l : libraries) { if ("net.legacyfabric".equals(l.groupId())) { return false; @@ -78,7 +78,7 @@ protected boolean matchLibrary(Library library, List libraries) { FABRIC_API("fabric-api") { @Override protected boolean matchLibrary(Library library, List libraries) { - return "net.fabricmc".equals(library.groupId()) && "fabric-api".equals(library.artifactId()); + return library.is("net.fabricmc", "fabric-api"); } }, FORGE("forge", ModLoaderType.FORGE) { @@ -107,7 +107,7 @@ protected boolean matchLibrary(Library library, List libraries) { CLEANROOM("cleanroom", ModLoaderType.CLEANROOM) { @Override protected boolean matchLibrary(Library library, List libraries) { - return "com.cleanroommc".equals(library.groupId()) && "cleanroom".equals(library.artifactId()); + return library.is("com.cleanroommc", "cleanroom"); } }, NEO_FORGE("neoforge", ModLoaderType.NEO_FORGE) { @@ -195,7 +195,7 @@ protected boolean matchLibrary(Library library, List libraries) { LITELOADER("liteloader", ModLoaderType.LITE_LOADER) { @Override protected boolean matchLibrary(Library library, List libraries) { - return "com.mumfrey".equals(library.groupId()) && "liteloader".equals(library.artifactId()); + return library.is("com.mumfrey", "liteloader"); } }, OPTIFINE("optifine") { @@ -209,13 +209,13 @@ protected boolean matchLibrary(Library library, List libraries) { QUILT("quilt", ModLoaderType.QUILT) { @Override protected boolean matchLibrary(Library library, List libraries) { - return "org.quiltmc".equals(library.groupId()) && "quilt-loader".equals(library.artifactId()); + return library.is("org.quiltmc", "quilt-loader"); } }, QUILT_API("quilt-api") { @Override protected boolean matchLibrary(Library library, List libraries) { - return "org.quiltmc".equals(library.groupId()) && "quilt-api".equals(library.artifactId()); + return library.is("org.quiltmc", "quilt-api"); } }, ; From aad41395b09f4ea177aa6ef7110266d272ca2d78 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 16 Aug 2026 21:29:03 +0800 Subject: [PATCH 194/199] refactor: enhance manifest resolution and streamline library task constructors --- .../hmcl/download/DefaultGameBuilder.java | 19 +++++++++++-------- .../hmcl/download/game/GameLibrariesTask.java | 12 +++++------- 2 files changed, 16 insertions(+), 15 deletions(-) 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 234af6a7761..80880add330 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -54,7 +54,8 @@ public DefaultDependencyManager getDependencyManager() { /// {@inheritDoc} /// /// Retains an unpublished working manifest, installs the configured game and optional loaders, - /// stages the completed manifest, and commits it once. Failure or cancellation aborts the draft. + /// resolves their patches into the final manifest, and commits it once. Failure or cancellation + /// aborts the draft. /// /// @return the build task /// @throws NullPointerException if [#id] was not set @@ -104,13 +105,15 @@ public Task buildAsync() { } } - return libraryTask.thenComposeAsync(manifest -> - new GameDownloadTask(dependencyManager, gameVersion, manifest) - .thenApplyAsync(minecraftJar -> { - draft.put(manifest); - draft.putPrimaryJar(id, minecraftJar); - return draft.commit().getInstance(id); - })) + return libraryTask.thenComposeAsync(manifest -> { + GameInstanceManifest resolvedManifest = repository.resolve(manifest).launchManifest(); + return new GameDownloadTask(dependencyManager, gameVersion, resolvedManifest) + .thenApplyAsync(minecraftJar -> { + draft.put(resolvedManifest); + draft.putPrimaryJar(id, minecraftJar); + return draft.commit().getInstance(id); + }); + }) .whenComplete(exception -> { if (draft.isOpen()) { draft.abort(); 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 a3f5040d5d0..7ed29a7bb81 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 @@ -59,7 +59,7 @@ public final class GameLibrariesTask extends Task { * Constructor. * * @param dependencyManager the dependency manager that can provides {@link GameRepository} - * @param manifest the game version + * @param manifest the game version */ public GameLibrariesTask(AbstractDependencyManager dependencyManager, GameInstanceManifest manifest, boolean integrityCheck) { this(dependencyManager, manifest, integrityCheck, dependencyManager.getGameRepository().resolve(manifest).launchManifest().getLibraries()); @@ -69,7 +69,7 @@ public GameLibrariesTask(AbstractDependencyManager dependencyManager, GameInstan * Constructor. * * @param dependencyManager the dependency manager that can provides {@link GameRepository} - * @param manifest the game version + * @param manifest the game version */ public GameLibrariesTask(AbstractDependencyManager dependencyManager, GameInstanceManifest manifest, boolean integrityCheck, List libraries) { this.dependencyManager = dependencyManager; @@ -142,7 +142,7 @@ public void execute() throws IOException { } // https://github.com/HMCL-dev/HMCL/issues/3975 - if ("net.minecraftforge".equals(library.groupId()) && "minecraftforge".equals(library.artifactId()) + if (library.is("net.minecraftforge", "minecraftforge") && gameRepository instanceof DefaultGameRepository defaultGameRepository) { List fmlLibs = getFMLLibs(library.version()); if (fmlLibs != null) { @@ -172,8 +172,7 @@ public void execute() throws IOException { throw new IOException("Cannot fix optifine", e); } } - } else if ("org.jackhuang.hmcl".equals(library.groupId()) - && "mmc-bootstrap".equals(library.artifactId())) { + } else if (library.is("org.jackhuang.hmcl", "mmc-bootstrap")) { if (!Files.exists(file)) { try (InputStream input = Objects.requireNonNull( GameLibrariesTask.class.getResourceAsStream( @@ -183,8 +182,7 @@ public void execute() throws IOException { Files.copy(input, file, StandardCopyOption.REPLACE_EXISTING); } } - } else if ("org.jackhuang.hmcl".equals(library.groupId()) - && "transformer-discovery-service".equals(library.artifactId())) { + } else if (library.is("org.jackhuang.hmcl", "transformer-discovery-service")) { try (InputStream input = Objects.requireNonNull( GameLibrariesTask.class.getResourceAsStream( "/assets/game/HMCLTransformerDiscoveryService-1.0.jar"), From 2c392fcb1442bfc50baefe99e3c4201253376166 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 16 Aug 2026 21:39:23 +0800 Subject: [PATCH 195/199] refactor: update DefaultGameBuilder to use DefaultGameRepositoryDraft for draft handling --- .../java/org/jackhuang/hmcl/download/DefaultGameBuilder.java | 4 ++-- .../org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java | 4 ++++ 2 files changed, 6 insertions(+), 2 deletions(-) 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 80880add330..c858ba1128d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -84,7 +84,7 @@ public Task buildAsync() { DefaultGameRepository repository = dependencyManager.getGameRepository(); //noinspection resource - GameRepositoryDraft draft = repository.openDraft(); + DefaultGameRepositoryDraft draft = repository.openDraft(); Task libraryTask = dependencyManager.installNewInstanceComponentAsync( id, new GameInstanceManifest(id), gameVersion, GameComponentType.GAME, gameVersion); @@ -106,7 +106,7 @@ public Task buildAsync() { } return libraryTask.thenComposeAsync(manifest -> { - GameInstanceManifest resolvedManifest = repository.resolve(manifest).launchManifest(); + GameInstanceManifest resolvedManifest = draft.getBaseSnapshot().resolve(manifest).launchManifest(); return new GameDownloadTask(dependencyManager, gameVersion, resolvedManifest) .thenApplyAsync(minecraftJar -> { draft.put(resolvedManifest); 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 280533c703e..d8caef6366c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepositoryDraft.java @@ -91,6 +91,10 @@ public DefaultGameRepository getRepository() { return repository; } + public DefaultGameRepositorySnapshot getBaseSnapshot() { + return baseSnapshot; + } + /// {@inheritDoc} @Override public GameRepositoryDraft.State getState() { From aa77e1e0ef16022e29435af81a324b42c66a90b2 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 16 Aug 2026 21:46:07 +0800 Subject: [PATCH 196/199] refactor: update Instances to use new variable name for game manifest --- .../org/jackhuang/hmcl/ui/instances/Instances.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) 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 66b925267d5..8c9603caf7c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/Instances.java @@ -190,11 +190,11 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { DefaultDependencyManager dependencyManager = repository.getDependency(); String gameVersion = manifest.id().id(); - GameInstanceManifest newVersion = manifest.withId(instanceId).withJar(instanceId); + GameInstanceManifest newManifest = manifest.withId(instanceId).withJar(instanceId); GameDownloadTask gameDownloadTask = new GameDownloadTask( dependencyManager, gameVersion, - newVersion); + newManifest); AtomicReference activeDraft = new AtomicReference<>(); Controllers.taskDialog( @@ -208,10 +208,10 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { Task.allOf( new GameAssetDownloadTask( dependencyManager, - newVersion, + newManifest, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true), - new GameLibrariesTask(dependencyManager, newVersion, true)) + new GameLibrariesTask(dependencyManager, newManifest, true)) .withRunAsync(() -> { // ignore failure }))) @@ -220,7 +220,7 @@ public static void installFromJson(HMCLGameRepository repository, Path file) { if (draft == null) { throw new IllegalStateException("Game repository draft is unavailable"); } - draft.put(newVersion); + draft.put(newManifest); draft.putPrimaryJar(instanceId, gameDownloadTask.getResult()); draft.commit(); }) From c4313f341ae89b001f0cd6a19b0540ea959144b1 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 16 Aug 2026 21:58:44 +0800 Subject: [PATCH 197/199] refactor: improve optifine handling logic in GameLibrariesTask --- .../hmcl/download/game/GameLibrariesTask.java | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) 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 7ed29a7bb81..6d817040d72 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameLibrariesTask.java @@ -162,14 +162,17 @@ public void execute() throws IOException { } Path file = gameRepository.getLayout().getLibraryFile(manifest.id(), library); - if ("optifine".equals(library.groupId()) && Files.exists(file) && GameVersionNumber.asGameVersion(gameRepository.getGameVersion(manifest).orElse(null)).compareTo("1.20.4") == 0) { - @Nullable String forgeVersion = GameComponentAnalyzer.analyze(manifest, GameVersionNumber.asGameVersion("1.20.4")) - .getVersion(GameComponentType.FORGE); - if (forgeVersion != null && GameComponentAnalyzer.FORGE_OPTIFINE_BROKEN_RANGE.contains(VersionNumber.asVersion(forgeVersion))) { - try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(file)) { - Files.deleteIfExists(fs2.getPath("/META-INF/mods.toml")); - } catch (IOException e) { - throw new IOException("Cannot fix optifine", e); + if ("optifine".equals(library.groupId()) && Files.exists(file)) { + if (Files.exists(file) && libraries.stream().filter(it -> it.is("optifine", "OptiFine")) + .anyMatch(it -> it.version().startsWith("1.20.4_"))) { + @Nullable String forgeVersion = GameComponentAnalyzer.analyze(manifest, GameVersionNumber.asGameVersion("1.20.4")) + .getVersion(GameComponentType.FORGE); + if (forgeVersion != null && GameComponentAnalyzer.FORGE_OPTIFINE_BROKEN_RANGE.contains(VersionNumber.asVersion(forgeVersion))) { + try (FileSystem fs2 = CompressingUtils.createWritableZipFileSystem(file)) { + Files.deleteIfExists(fs2.getPath("/META-INF/mods.toml")); + } catch (IOException e) { + throw new IOException("Cannot fix optifine", e); + } } } } else if (library.is("org.jackhuang.hmcl", "mmc-bootstrap")) { From 6760279724bb937cf7438dd3a02d6b9ccdc16712 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 17 Aug 2026 20:18:09 +0800 Subject: [PATCH 198/199] Use content-addressed cache for game client JARs Assisted-by: codex:gpt-5.6-sol --- .../download/DefaultDependencyManager.java | 7 +- .../hmcl/download/DefaultGameBuilder.java | 2 +- .../cleanroom/CleanroomInstallTask.java | 2 +- .../hmcl/download/forge/ForgeInstallTask.java | 4 +- .../hmcl/download/game/GameDownloadTask.java | 115 ++++-------------- .../hmcl/download/game/GameInstallTask.java | 2 +- .../neoforge/NeoForgeInstallTask.java | 4 +- .../optifine/OptiFineInstallTask.java | 2 +- .../optifine/OptiFineRemoteVersion.java | 2 +- .../multimc/MultiMCModpackInstallTask.java | 23 +--- .../jackhuang/hmcl/task/CacheFileTask.java | 90 +++++++++++--- .../hmcl/game/DefaultGameInstanceTest.java | 61 ++++++---- .../jackhuang/hmcl/task/FetchTaskTest.java | 45 +++++++ 13 files changed, 199 insertions(+), 160 deletions(-) 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 b0c928a6ef3..b9b34c3b62f 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultDependencyManager.java @@ -103,10 +103,11 @@ public Task checkGameCompletionAsync( return Task.allOf( Task.composeAsync(() -> { - Path versionJar = instance.getInstanceJarFile(); + Path instanceJar = instance.getInstanceJarFile(); - return Files.notExists(versionJar) || FileUtils.size(versionJar) == 0L - ? new GameDownloadTask(this, null, manifest, versionJar) + return Files.notExists(instanceJar) || FileUtils.size(instanceJar) == 0L + ? new GameDownloadTask(this, manifest).thenAcceptAsync( + cachedJar -> FileUtils.copyFile(cachedJar, instanceJar)) : null; }).thenComposeAsync(checkPatchCompletionAsync(instance, manifest, integrityCheck)), new GameAssetDownloadTask(this, manifest, GameAssetDownloadTask.DOWNLOAD_INDEX_IF_NECESSARY, integrityCheck) 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 c858ba1128d..c5b0817f996 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/DefaultGameBuilder.java @@ -107,7 +107,7 @@ public Task buildAsync() { return libraryTask.thenComposeAsync(manifest -> { GameInstanceManifest resolvedManifest = draft.getBaseSnapshot().resolve(manifest).launchManifest(); - return new GameDownloadTask(dependencyManager, gameVersion, resolvedManifest) + return new GameDownloadTask(dependencyManager, resolvedManifest) .thenApplyAsync(minecraftJar -> { draft.put(resolvedManifest); draft.putPrimaryJar(id, minecraftJar); 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 df442c13b8b..0882a250758 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/cleanroom/CleanroomInstallTask.java @@ -146,7 +146,7 @@ public void execute() throws IOException, VersionMismatchException, UnsupportedI cleanroomVersion = selfVersion; } - task = new GameDownloadTask(dependencyManager, gameVersion, manifest) + task = new GameDownloadTask(dependencyManager, manifest) .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( dependencyManager, manifest, 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 4de80864866..96a1df8a929 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeInstallTask.java @@ -108,7 +108,7 @@ public void execute() throws IOException, VersionMismatchException, UnsupportedI } if (detectForgeInstallerType(remote.getGameVersion(), installer)) { - dependency = new GameDownloadTask(dependencyManager, remote.getGameVersion(), manifest) + dependency = new GameDownloadTask(dependencyManager, manifest) .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( dependencyManager, manifest, @@ -173,7 +173,7 @@ public static Task install( ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion); - return new GameDownloadTask(dependencyManager, gameVersion, manifest) + return new GameDownloadTask(dependencyManager, manifest) .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( dependencyManager, manifest, 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 0af0974fe53..28774749716 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 @@ -20,9 +20,8 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.game.DownloadInfo; import org.jackhuang.hmcl.game.GameInstanceManifest; -import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.task.CacheFileTask; import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.util.CacheRepository; import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; @@ -33,7 +32,7 @@ import java.util.Collection; import java.util.List; -/// Downloads a Minecraft client JAR to shared cache storage or an explicitly fixed destination. +/// Obtains a Minecraft client JAR from content-addressed cache storage. @NotNullByDefault public final class GameDownloadTask extends Task { @@ -43,87 +42,23 @@ public final class GameDownloadTask extends Task { /// The resolved manifest that supplies client download metadata. private final GameInstanceManifest manifest; - /// Destination fixed when this task is created. - private final Path jar; - - /// Optional pre-existing file that may seed an explicit destination. - private final @Nullable Path candidate; - - /// The file-download task created during execution. + /// The cache task created during execution. private final List> dependencies = new ArrayList<>(); - /// Creates a task that downloads a versioned client JAR into shared cache storage. + /// Creates a task that returns a cached Minecraft client JAR. /// /// @param dependencyManager the dependency manager used for resolution and downloading - /// @param gameVersion the Minecraft version used as the shared-cache key /// @param manifest the manifest supplying client download metadata public GameDownloadTask( DefaultDependencyManager dependencyManager, - String gameVersion, GameInstanceManifest manifest) { this.dependencyManager = dependencyManager; this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); - this.jar = getSharedJarPath(dependencyManager, gameVersion); - this.candidate = null; - - setSignificance(TaskSignificance.MODERATE); - } - - /// Creates a task that writes the client jar to an explicit fixed destination. - /// - /// @param dependencyManager the dependency manager used for resolution and downloading - /// @param gameVersion the Minecraft version used as a cache key, or `null` - /// @param manifest the manifest supplying client download metadata - /// @param jar the destination jar path - public GameDownloadTask( - DefaultDependencyManager dependencyManager, - @Nullable String gameVersion, - GameInstanceManifest manifest, - Path jar) { - this.dependencyManager = dependencyManager; - this.manifest = dependencyManager.getGameRepository().resolve(manifest).launchManifest(); - this.jar = jar; - @Nullable Path sharedJar = gameVersion != null ? getSharedJarPath(dependencyManager, gameVersion) : null; - this.candidate = sharedJar != null && !sameNormalizedPath(sharedJar, jar) ? sharedJar : null; setSignificance(TaskSignificance.MODERATE); } - /// Returns the shared client-JAR path for a Minecraft version. - /// - /// @param dependencyManager the dependency manager owning the shared cache - /// @param gameVersion the Minecraft version used as the file name - /// @return the normalized path below the cache's `jars` directory - /// @throws IllegalArgumentException if the version is blank or would escape the `jars` - /// directory - private static Path getSharedJarPath( - DefaultDependencyManager dependencyManager, - String gameVersion) { - if (gameVersion.isBlank()) { - throw new IllegalArgumentException("Minecraft version must not be blank"); - } - Path directory = dependencyManager.getCacheRepository() - .getCommonDirectory() - .resolve("jars") - .toAbsolutePath() - .normalize(); - Path destination = directory.resolve(gameVersion + ".jar").normalize(); - if (!directory.equals(destination.getParent())) { - throw new IllegalArgumentException("Invalid Minecraft version for cache path: " + gameVersion); - } - return destination; - } - - /// Returns whether two paths identify the same normalized absolute path. - /// - /// @param first the first path - /// @param second the second path - /// @return whether the normalized paths are equal - private static boolean sameNormalizedPath(Path first, Path second) { - return first.toAbsolutePath().normalize().equals(second.toAbsolutePath().normalize()); - } - - /// Returns the download created by [#execute()], if execution has started. + /// Returns the cache operation created by [#execute()], if execution has started. /// /// @return the live dependency collection @Override @@ -131,26 +66,22 @@ public Collection> getDependencies() { return dependencies; } - /// Creates the file-download dependency unless the destination already has the expected content. + /// Creates the checksum-aware cache download. @Override - public void execute() throws IOException { + public void execute() { DownloadInfo downloadInfo = manifest.getDownloadInfo(); - if (Files.isRegularFile(jar) && downloadInfo.validateChecksum(jar, false)) { - return; - } - - var task = new FileDownloadTask( - dependencyManager.getDownloadProvider().injectURLWithCandidates(downloadInfo.getUrl()), - jar, - FileDownloadTask.IntegrityCheck.of(CacheRepository.SHA1, downloadInfo.getSha1())); - task.setCaching(true); - task.setCacheRepository(dependencyManager.getCacheRepository()); - - if (candidate != null) { - task.setCandidate(candidate); - } - - dependencies.add(task); + @Nullable String sha1 = downloadInfo.getSha1(); + CacheFileTask cacheTask = sha1 != null + ? new CacheFileTask( + dependencyManager.getDownloadProvider() + .injectURLWithCandidates(downloadInfo.getUrl()), + sha1) + : new CacheFileTask( + dependencyManager.getDownloadProvider() + .injectURLWithCandidates(downloadInfo.getUrl())); + cacheTask.setCacheRepository(dependencyManager.getCacheRepository()); + cacheTask.storeTo(this::setResult); + dependencies.add(cacheTask); } /// Requests post-execution so the completed destination can be returned. @@ -163,12 +94,12 @@ public boolean doPostExecute() { /// Returns the downloaded or previously validated client JAR. /// - /// @throws IOException if the destination was not materialized + /// @throws IOException if no regular cached JAR is available @Override public void postExecute() throws IOException { - if (!Files.isRegularFile(jar)) { - throw new IOException("Minecraft client JAR was not downloaded: " + jar); + @Nullable Path result = getResult(); + if (result == null || !Files.isRegularFile(result)) { + throw new IOException("Minecraft client JAR was not downloaded"); } - setResult(jar); } } 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 0fbd1ee3760..0ca46588df3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameInstallTask.java @@ -95,7 +95,7 @@ public void execute() throws Exception { GameInstanceManifest newManifest = new GameInstanceManifest(this.manifest.id()).addPatch(patch); dependencies.add(Task.allOf( - new GameDownloadTask(dependencyManager, remote.getGameVersion(), newManifest), + new GameDownloadTask(dependencyManager, newManifest), Task.allOf( new GameAssetDownloadTask(dependencyManager, newManifest, GameAssetDownloadTask.DOWNLOAD_INDEX_FORCIBLY, true), new GameLibrariesTask(dependencyManager, newManifest, true) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java index 0c368bb1f2d..6181794c583 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeInstallTask.java @@ -126,7 +126,7 @@ public static Task install( ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion); - return new GameDownloadTask(dependencyManager, gameVersion, manifest) + return new GameDownloadTask(dependencyManager, manifest) .thenComposeAsync(minecraftJar -> new ForgeNewInstallTask( dependencyManager, manifest, @@ -146,7 +146,7 @@ public static Task install( ForgeNewInstallProfile profile = JsonUtils.fromNonNullJson(installProfileText, ForgeNewInstallProfile.class); if (!gameVersion.equals(profile.getMinecraft())) throw new VersionMismatchException(profile.getMinecraft(), gameVersion); - return new GameDownloadTask(dependencyManager, gameVersion, manifest) + return new GameDownloadTask(dependencyManager, manifest) .thenComposeAsync(minecraftJar -> new NeoForgeOldInstallTask( dependencyManager, manifest, 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 fd93136d3f9..10f1c194486 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineInstallTask.java @@ -287,7 +287,7 @@ public static Task install( ofEdition + "_" + ofRelease, Collections.singletonList(""), false); - return new GameDownloadTask(dependencyManager, gameVersion, version) + return new GameDownloadTask(dependencyManager, version) .thenComposeAsync(minecraftJar -> new OptiFineInstallTask( dependencyManager, version, 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 deb3a0d6b5e..b7b788b8ea3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/optifine/OptiFineRemoteVersion.java @@ -40,7 +40,7 @@ public String getFullVersion() { @Override public Task getInstallTask(DefaultDependencyManager dependencyManager, GameInstanceManifest baseVersion) { - return new GameDownloadTask(dependencyManager, getGameVersion(), baseVersion) + return new GameDownloadTask(dependencyManager, baseVersion) .thenComposeAsync(minecraftJar -> new OptiFineInstallTask( dependencyManager, baseVersion, 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 967a4cb3216..b957a771a51 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackInstallTask.java @@ -296,26 +296,9 @@ public void execute() throws Exception { true )); - Artifact mainJarArtifact = artifact.getMainJar().artifact(); - String gameVersion = artifact.getGameVersion(); - if (gameVersion != null && - "com.mojang".equals(mainJarArtifact.getGroup()) && - "minecraft".equals(mainJarArtifact.getName()) && - Objects.equals(gameVersion, mainJarArtifact.getVersion()) && - "client".equals(mainJarArtifact.getClassifier()) - ) { - dependencies.add(new GameDownloadTask( - dependencyManager, - gameVersion, - instanceManifest, - repository.getInstanceJar(instanceManifest))); - } else { - dependencies.add(new GameDownloadTask( - dependencyManager, - null, - instanceManifest, - repository.getInstanceJar(instanceManifest))); - } + Path instanceJar = repository.getInstanceJar(instanceManifest); + dependencies.add(new GameDownloadTask(dependencyManager, instanceManifest) + .thenAcceptAsync(cachedJar -> FileUtils.copyFile(cachedJar, instanceJar))); } setResult(artifact); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CacheFileTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CacheFileTask.java index 78ffd655d3e..37c9433e7ed 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CacheFileTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/task/CacheFileTask.java @@ -18,6 +18,8 @@ package org.jackhuang.hmcl.task; import org.jackhuang.hmcl.util.CacheRepository; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.io.ChecksumMismatchException; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.UrlResponseInfo; import org.jetbrains.annotations.NotNull; @@ -34,35 +36,79 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG; -/** - * Download a file to cache repository. - * - * @author Glavo - */ +/// Downloads a remote file to a cache repository. +/// +/// @author Glavo public final class CacheFileTask extends FetchTask { + /// Expected SHA-1 checksum, or `null` when the remote cache policy determines reuse. + private final @Nullable String expectedSha1; + + /// Creates a task for one URI string using remote cache metadata. + /// + /// @param uri the HTTP or HTTPS URI string public CacheFileTask(@NotNull String uri) { this(NetworkUtils.toURI(uri)); } + /// Creates a task for one URI using remote cache metadata. + /// + /// @param uri the HTTP or HTTPS URI public CacheFileTask(@NotNull URI uri) { - super(List.of(uri)); - setName(uri.toString()); - - if (!NetworkUtils.isHttpUri(uri)) - throw new IllegalArgumentException(uri.toString()); + this(List.of(uri)); } + /// Creates a task for candidate URIs using remote cache metadata. + /// + /// @param uris candidate download URIs in attempt order public CacheFileTask(@NotNull List<@NotNull URI> uris) { super(uris); + this.expectedSha1 = null; + validateUris(uris); + setName(uris.get(0).toString()); + } + + /// Creates a task that returns content cached under a verified SHA-1 checksum. + /// + /// @param uris candidate download URIs in attempt order + /// @param expectedSha1 the expected SHA-1 checksum + public CacheFileTask( + @NotNull List<@NotNull URI> uris, + @NotNull String expectedSha1) { + super(uris); + if (!DigestUtils.isSha1Digest(expectedSha1)) { + throw new IllegalArgumentException("Invalid SHA-1 checksum: " + expectedSha1); + } + this.expectedSha1 = expectedSha1.toLowerCase(Locale.ROOT); + validateUris(uris); setName(uris.get(0).toString()); + } - if (!uris.stream().allMatch(NetworkUtils::isHttpUri)) + /// Verifies that all candidate URIs use HTTP or HTTPS. + /// + /// @param uris the candidate URIs + private static void validateUris(@NotNull List<@NotNull URI> uris) { + if (!uris.stream().allMatch(NetworkUtils::isHttpUri)) { throw new IllegalArgumentException(uris.toString()); + } } + /// Selects a verified content-addressed entry or the applicable remote-cache policy. + /// + /// @return the cache action to perform before downloading @Override protected EnumCheckETag shouldCheckETag() { + if (expectedSha1 != null) { + Optional cached = repository.checkExistentFile( + null, CacheRepository.SHA1, expectedSha1); + if (cached.isPresent()) { + setResult(cached.get()); + LOG.info("Using cached file with SHA-1 " + expectedSha1); + return EnumCheckETag.CACHED; + } + return EnumCheckETag.NOT_CHECK_E_TAG; + } + // Check cache for (URI uri : uris) { try { @@ -82,10 +128,18 @@ protected void useCachedResult(Path cache) { setResult(cache); } + /// Creates a temporary sink that publishes a successful download to the cache repository. + /// + /// @param response the HTTP response metadata + /// @param checkETag whether remote cache metadata is being checked + /// @param bmclapiHash the hash supplied by BMCLAPI, or `null` + /// @return the temporary download sink + /// @throws IOException if the temporary file cannot be created @Override protected Context getContext(@Nullable UrlResponseInfo response, boolean checkETag, @Nullable String bmclapiHash) throws IOException { - assert checkETag; - assert response != null; + if (expectedSha1 == null && (!checkETag || response == null)) { + throw new IOException("Remote response metadata is unavailable"); + } return new Context() { private final Path temp = Files.createTempFile("hmcl-download-", null); @@ -124,7 +178,15 @@ public void close() throws IOException { } try { - setResult(repository.cacheRemoteFile(response, temp)); + if (expectedSha1 != null) { + ChecksumMismatchException.verifyChecksum( + temp, CacheRepository.SHA1, expectedSha1); + setResult(repository.cacheFile( + temp, CacheRepository.SHA1, expectedSha1)); + } else { + setResult(repository.cacheRemoteFile( + Objects.requireNonNull(response), temp)); + } } finally { deleteTempFile(); } diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java index cb8fe71f2a2..c126c92e773 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -27,7 +27,6 @@ import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask; import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask; import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask; -import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.versioning.GameVersionNumber; @@ -202,43 +201,61 @@ public void testExplicitManifestDoesNotReuseDifferentCachedManifest(@TempDir Pat assertEquals(Optional.of("1.21.1"), repository.getGameVersion(requestedManifest)); } - /// A game download with an explicit destination does not follow a later repository snapshot. + /// A cached game download can be materialized at an explicit destination. @Test - public void testGameDownloadKeepsExplicitDestination(@TempDir Path tempDirectory) throws IOException { + public void testGameDownloadMaterializesExplicitDestination(@TempDir Path tempDirectory) + throws Exception { TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultCacheRepository cacheRepository = + new DefaultCacheRepository(tempDirectory.resolve("cache")); DefaultDependencyManager dependencyManager = new DefaultDependencyManager( repository, new MojangDownloadProvider(), - new DefaultCacheRepository(tempDirectory.resolve("cache"))); - GameInstanceManifest manifest = new GameInstanceManifest(new GameInstanceID("instance")); + cacheRepository); + Path source = tempDirectory.resolve("client.jar"); + Files.writeString(source, "client"); + String sha1 = DigestUtils.digestToString("SHA-1", source); + Path cached = cacheRepository.cacheFile(source, "SHA-1", sha1); + GameInstanceManifest manifest = new GameInstanceManifest(new GameInstanceID("instance")) + .withDownloads(Map.of( + DownloadType.CLIENT, + new DownloadInfo("https://example.invalid/client.jar", sha1))); Path destination = tempDirectory.resolve("fixed.jar"); - GameDownloadTask task = new GameDownloadTask(dependencyManager, null, manifest, destination); - task.execute(); + var task = new GameDownloadTask(dependencyManager, manifest) + .thenAcceptAsync(cachedJar -> Files.copy(cachedJar, destination)); - FileDownloadTask download = (FileDownloadTask) task.getDependencies().iterator().next(); - assertEquals(destination, download.getPath()); + assertTrue(task.executor().test()); + assertEquals("client", Files.readString(destination)); + assertEquals("client", Files.readString(cached)); } - /// A versioned game download uses shared cache storage instead of the instance tree. + /// A game download returns the content-addressed cache file without a version-named copy. @Test - public void testGameDownloadUsesSharedDestination(@TempDir Path tempDirectory) throws IOException { + public void testGameDownloadReturnsContentAddressedCacheFile(@TempDir Path tempDirectory) + throws Exception { TestRepository repository = new TestRepository(tempDirectory.resolve("game")); Path cacheDirectory = tempDirectory.resolve("cache"); + DefaultCacheRepository cacheRepository = new DefaultCacheRepository(cacheDirectory); DefaultDependencyManager dependencyManager = new DefaultDependencyManager( repository, new MojangDownloadProvider(), - new DefaultCacheRepository(cacheDirectory)); - GameInstanceManifest manifest = new GameInstanceManifest(new GameInstanceID("instance")); - - GameDownloadTask task = new GameDownloadTask(dependencyManager, "1.21.1", manifest); - task.execute(); - - FileDownloadTask download = (FileDownloadTask) task.getDependencies().iterator().next(); - assertEquals( - cacheDirectory.resolve("jars/1.21.1.jar").toAbsolutePath().normalize(), - download.getPath()); - assertFalse(download.getPath().startsWith(repository.getLayout().getInstanceRoot(manifest.id()))); + cacheRepository); + Path source = tempDirectory.resolve("client.jar"); + Files.writeString(source, "client"); + String sha1 = DigestUtils.digestToString("SHA-1", source); + Path cached = cacheRepository.cacheFile(source, "SHA-1", sha1); + GameInstanceManifest manifest = new GameInstanceManifest(new GameInstanceID("instance")) + .withDownloads(Map.of( + DownloadType.CLIENT, + new DownloadInfo("https://example.invalid/client.jar", sha1))); + + GameDownloadTask task = new GameDownloadTask(dependencyManager, manifest); + + assertTrue(task.executor().test()); + assertEquals(cached, task.getResult()); + assertFalse(Files.exists(cacheDirectory.resolve("jars"))); + assertFalse(cached.startsWith(repository.getLayout().getInstanceRoot(manifest.id()))); } /// Keeps the detached client JAR path distinct from the Minecraft version processor variable. diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/task/FetchTaskTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/task/FetchTaskTest.java index da1dd32c2f4..da6000664bd 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/task/FetchTaskTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/task/FetchTaskTest.java @@ -20,6 +20,8 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpServer; import org.jackhuang.hmcl.util.CacheRepository; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.io.ChecksumMismatchException; import org.jackhuang.hmcl.util.io.NetworkUtils; import org.jackhuang.hmcl.util.io.UrlResponseInfo; import org.jetbrains.annotations.NotNullByDefault; @@ -37,6 +39,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Objects; import java.util.concurrent.atomic.AtomicInteger; import static java.nio.charset.StandardCharsets.UTF_8; @@ -72,6 +75,48 @@ public void checksumMismatchFailsWithoutReplacingTarget(@TempDir Path tempDir) t } } + /// Ensures matching content is published and reused by its expected SHA-1. + @Test + public void cacheFileTaskUsesExpectedSha1(@TempDir Path tempDir) throws IOException { + byte[] data = "cached content".getBytes(UTF_8); + String sha1 = DigestUtils.digestToString(CacheRepository.SHA1, data); + URI uri = URI.create("https://example.invalid/file"); + CacheRepository repository = newRepository(tempDir); + CacheFileTask first = new CacheFileTask(List.of(uri), sha1); + first.setCacheRepository(repository); + + try (FetchTask.Context context = first.getContext(null, false, null)) { + context.write(data, 0, data.length); + context.withResult(true); + } + + Path cached = Objects.requireNonNull(first.getResult()); + assertArrayEquals(data, Files.readAllBytes(cached)); + + CacheFileTask second = new CacheFileTask(List.of(uri), sha1); + second.setCacheRepository(repository); + assertEquals(FetchTask.EnumCheckETag.CACHED, second.shouldCheckETag()); + assertEquals(cached, second.getResult()); + } + + /// Ensures mismatched content is not published under an expected SHA-1. + @Test + public void cacheFileTaskRejectsMismatchedSha1(@TempDir Path tempDir) throws IOException { + byte[] data = "unexpected content".getBytes(UTF_8); + String expectedSha1 = "0000000000000000000000000000000000000000"; + CacheRepository repository = newRepository(tempDir); + CacheFileTask task = new CacheFileTask( + List.of(URI.create("https://example.invalid/file")), expectedSha1); + task.setCacheRepository(repository); + FetchTask.Context context = task.getContext(null, false, null); + context.write(data, 0, data.length); + context.withResult(true); + + assertThrows(ChecksumMismatchException.class, context::close); + assertTrue(repository.checkExistentFile( + null, CacheRepository.SHA1, expectedSha1).isEmpty()); + } + /// Ensures a mismatched Content-Range response is rejected before appending bytes. @Test public void invalidContentRangeFallsBackToFullDownload(@TempDir Path tempDir) throws IOException { From 23faf69b1fb49926918044ad5aec1c269cfa3c79 Mon Sep 17 00:00:00 2001 From: Glavo Date: Mon, 17 Aug 2026 20:40:23 +0800 Subject: [PATCH 199/199] refactor: isolate Minecraft JAR for processor use in Forge installation tasks --- .../download/forge/ForgeNewInstallTask.java | 9 ++-- .../hmcl/download/game/GameDownloadTask.java | 3 +- .../neoforge/NeoForgeOldInstallTask.java | 9 ++-- .../hmcl/game/DefaultGameInstanceTest.java | 48 +++++++++++++++++-- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java index 9073cf82089..2d5d60b41fe 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/forge/ForgeNewInstallTask.java @@ -189,7 +189,7 @@ public void execute() throws Exception { private final DefaultDependencyManager dependencyManager; private final DefaultGameRepository gameRepository; private final GameInstanceManifest manifest; - /// Vanilla client JAR used as processor input. + /// Source vanilla client JAR copied before processors are invoked. private final Path minecraftJar; private final Path installer; private final List> dependents = new ArrayList<>(1); @@ -207,7 +207,7 @@ public void execute() throws Exception { /// /// @param dependencyManager repository-scoped download services /// @param manifest working manifest receiving the Forge patch - /// @param minecraftJar vanilla client JAR for the target Minecraft version + /// @param minecraftJar source vanilla client JAR copied for processor use /// @param selfVersion Forge version recorded in the returned patch /// @param installer Forge installer JAR public ForgeNewInstallTask( @@ -399,6 +399,9 @@ public void execute() throws Exception { throw new FileNotFoundException("Minecraft client JAR not found: " + minecraftJar); } tempDir = Files.createTempDirectory("forge_installer"); + // External processors must not receive the shared cache path. + Path isolatedMinecraftJar = tempDir.resolve("minecraft.jar"); + FileUtils.copyFile(minecraftJar, isolatedMinecraftJar); Map vars = new HashMap<>(); @@ -420,7 +423,7 @@ public void execute() throws Exception { } vars.put("SIDE", "client"); - vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(minecraftJar)); + vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(isolatedMinecraftJar)); vars.put("MINECRAFT_VERSION", profile.getMinecraft()); vars.put("ROOT", FileUtils.getAbsolutePath(gameRepository.getBaseDirectory())); vars.put("INSTALLER", installer.toAbsolutePath().toString()); 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 28774749716..6709cb5c3b6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/game/GameDownloadTask.java @@ -85,8 +85,6 @@ public void execute() { } /// Requests post-execution so the completed destination can be returned. - /// - /// @return `true` @Override public boolean doPostExecute() { return true; @@ -98,6 +96,7 @@ public boolean doPostExecute() { @Override public void postExecute() throws IOException { @Nullable Path result = getResult(); + //noinspection ConstantValue if (result == null || !Files.isRegularFile(result)) { throw new IOException("Minecraft client JAR was not downloaded"); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java index 87c3313ff38..522729d4df5 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/download/neoforge/NeoForgeOldInstallTask.java @@ -173,7 +173,7 @@ public void execute() throws Exception { private final DefaultDependencyManager dependencyManager; private final DefaultGameRepository gameRepository; private final GameInstanceManifest manifest; - /// Vanilla client JAR used as processor input. + /// Source vanilla client JAR copied before processors are invoked. private final Path minecraftJar; private final Path installer; private final List> dependents = new ArrayList<>(1); @@ -191,7 +191,7 @@ public void execute() throws Exception { /// /// @param dependencyManager repository-scoped download services /// @param manifest working manifest receiving the NeoForge patch - /// @param minecraftJar vanilla client JAR for the target Minecraft version + /// @param minecraftJar source vanilla client JAR copied for processor use /// @param selfVersion NeoForge version recorded in the returned patch /// @param installer NeoForge installer JAR NeoForgeOldInstallTask( @@ -383,6 +383,9 @@ public void execute() throws Exception { throw new FileNotFoundException("Minecraft client JAR not found: " + minecraftJar); } tempDir = Files.createTempDirectory("neoforge_installer"); + // External processors must not receive the shared cache path. + Path isolatedMinecraftJar = tempDir.resolve("minecraft.jar"); + FileUtils.copyFile(minecraftJar, isolatedMinecraftJar); Map vars = new HashMap<>(); @@ -404,7 +407,7 @@ public void execute() throws Exception { } vars.put("SIDE", "client"); - vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(minecraftJar)); + vars.put("MINECRAFT_JAR", FileUtils.getAbsolutePath(isolatedMinecraftJar)); vars.put("MINECRAFT_VERSION", profile.getMinecraft()); vars.put("ROOT", FileUtils.getAbsolutePath(gameRepository.getBaseDirectory())); vars.put("INSTALLER", installer.toAbsolutePath().toString()); 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 c126c92e773..ab9df59627f 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -274,6 +274,7 @@ public void testForgeProcessorSeparatesMinecraftJarAndVersion(@TempDir Path temp writeForgeProcessorFixture( installer, versionMarker.toAbsolutePath().normalize().toString(), + "{MINECRAFT_VERSION}", markerSha1); TestRepository repository = new TestRepository(tempDirectory.resolve("game")); @@ -298,6 +299,42 @@ protected void updateProgressImmediately(double progress) { assertTrue(Files.isRegularFile(minecraftJar)); } + /// Prevents processor output handling from deleting the source Minecraft JAR. + @Test + public void testForgeProcessorUsesDisposableMinecraftJar(@TempDir Path tempDirectory) + throws IOException { + Path minecraftJar = tempDirectory.resolve("cache/client.jar"); + Files.createDirectories(minecraftJar.getParent()); + Files.writeString(minecraftJar, "client"); + + Path installer = tempDirectory.resolve("forge-installer.jar"); + writeForgeProcessorFixture( + installer, + "1.20.1", + "{MINECRAFT_JAR}", + "0000000000000000000000000000000000000000"); + + TestRepository repository = new TestRepository(tempDirectory.resolve("game")); + DefaultDependencyManager dependencyManager = new DefaultDependencyManager( + repository, + new MojangDownloadProvider(), + new DefaultCacheRepository(tempDirectory.resolve("download-cache"))); + ForgeNewInstallTask task = new ForgeNewInstallTask( + dependencyManager, + new GameInstanceManifest(new GameInstanceID("instance")), + minecraftJar, + "forge-test", + installer) { + @Override + protected void updateProgressImmediately(double progress) { + // Avoid JavaFX toolkit initialization in this isolated processor test. + } + }; + + assertFalse(task.executor().test()); + assertEquals("client", Files.readString(minecraftJar)); + } + /// Legacy verification fixes the captured instance jar rather than a newer same-id snapshot. @Test public void testVerificationFixKeepsCapturedInstance(@TempDir Path tempDirectory) throws IOException { @@ -398,15 +435,16 @@ private static void writeSignedJar(Path jar) throws IOException { } } - /// Writes a Forge installer fixture whose processor output is keyed by - /// `{MINECRAFT_VERSION}`. + /// Writes a Forge installer fixture with one processor output. /// - /// @param installer the installer JAR path + /// @param installer the installer JAR path /// @param minecraftVersion the value stored in the install profile's `minecraft` field - /// @param outputSha1 expected checksum for the processor output + /// @param outputKey processor output path expression + /// @param outputSha1 expected checksum for the processor output private static void writeForgeProcessorFixture( Path installer, String minecraftVersion, + String outputKey, String outputSha1) throws IOException { Map profile = Map.of( "spec", 1, @@ -416,7 +454,7 @@ private static void writeForgeProcessorFixture( "libraries", List.of(), "processors", List.of(Map.of( "jar", "example:processor:1.0", - "outputs", Map.of("{MINECRAFT_VERSION}", outputSha1)))); + "outputs", Map.of(outputKey, outputSha1)))); try (ZipOutputStream output = new ZipOutputStream(Files.newOutputStream(installer))) { writeZipEntry(output, "install_profile.json", JsonUtils.GSON.toJson(profile));