From 79e803aaa5ec651e5dbbdb374e913835d0551d72 Mon Sep 17 00:00:00 2001 From: Glavo Date: Sun, 2 Aug 2026 20:46:01 +0800 Subject: [PATCH 001/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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/114] 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());