From 7d4f0783363aa6b89f125168b325df54de5393da Mon Sep 17 00:00:00 2001 From: xianaldai <178786085+xianaldai@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:01:04 +0800 Subject: [PATCH] perf: cache parsed mod metadata and decoded mod icons Parsing a mod file opens its archive and reads its metadata entry, and every refresh of the mods directory repeated that work for every mod, even when nothing had changed. Parse each file once and cache the result keyed by its size and modification time, so a refresh of an unchanged directory only stats the files. Parsing also no longer runs on the manager lock, which lets the files be parsed in parallel on the first refresh. To make that possible the metadata readers return a new LocalModFile.Metadata value instead of a LocalModFile. A LocalModFile is bound to its manager and registers itself with the LocalMod it was created for, so it cannot be built off the manager lock nor reused across refreshes; the owning manager builds it from the parsed value when it merges the results. Two other things the parse phase was wasting time on: - ZipFileTree.getEntry falls back to indexing every entry of the archive whenever a name is absent, and the readers probe for optional entries constantly. Look those up in the reader's own name index instead. - A reload of the mod list rebuilds every item, so an icon held on the item was decoded again on the next reload. Hold the icons on the page, keyed by path, size and modification time, and let a rebuilt item reuse the icon already decoded for its file. --- .../hmcl/ui/instances/ModListPage.java | 62 +++++- .../hmcl/addon/meta/FabricModMetadata.java | 6 +- .../hmcl/addon/meta/ForgeNewModMetadata.java | 27 ++- .../hmcl/addon/meta/ForgeOldModMetadata.java | 6 +- .../hmcl/addon/meta/LiteModMetadata.java | 6 +- .../hmcl/addon/meta/QuiltModMetadata.java | 13 +- .../hmcl/addon/mod/LocalModFile.java | 24 ++- .../jackhuang/hmcl/addon/mod/ModManager.java | 184 +++++++++++++----- .../util/javafx/ItemPropertyAsyncCache.java | 44 +++++ 9 files changed, 281 insertions(+), 91 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 8de0d08066b..3ddbc7bc282 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 @@ -70,14 +70,19 @@ import java.io.IOException; import java.io.UncheckedIOException; +import java.lang.ref.SoftReference; import java.lang.ref.WeakReference; import java.nio.file.FileSystem; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; import java.util.*; import java.util.Objects; import java.util.concurrent.CancellationException; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; @@ -93,12 +98,37 @@ public final class ModListPage extends ListPageBase i private final ReentrantLock lock = new ReentrantLock(); private final WeakListenerHolder listenerHolder = new WeakListenerHolder(); + /// Icons decoded from mod files, keyed by the file state and loader they were decoded from. + /// + /// A reload builds a fresh [ModInfoObject] for every mod, so an icon held on the item would be + /// decoded again on the next reload. + private final ConcurrentMap>> icons = new ConcurrentHashMap<>(); + private ModManager modManager; private @Nullable HMCLGameInstance gameInstance; private String gameVersion; final EnumSet supportedLoaders = EnumSet.noneOf(ModLoaderType.class); + /// A mod without a logo falls back to the icon of its loader, so the loader is part of the key. + private record IconKey(Path file, long size, long lastModified, ModLoaderType loaderType) { + } + + private static IconKey iconKeyOf(LocalModFile modInfo) { + Path file = modInfo.getFile(); + ModLoaderType loaderType = modInfo.getModLoaderType(); + try { + BasicFileAttributes attributes = Files.readAttributes(file, BasicFileAttributes.class); + return new IconKey(file, attributes.size(), attributes.lastModifiedTime().toMillis(), loaderType); + } catch (IOException e) { + // The file vanished between the scan and this call; an unmatched key is enough. + return new IconKey(file, -1, -1, loaderType); + } + } + + private record LoadedMods(List items, Set iconKeys) { + } + /// Creates a mod list that reloads when `instanceContext` changes. /// /// @param instanceContext the parent page's instance property @@ -153,13 +183,24 @@ private void loadMods(ModManager modManager) { lock.lock(); try { modManager.refresh(); - return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList(); + + List files = modManager.getLocalFiles(); + Set iconKeys = new HashSet<>(files.size() * 2); + List items = files.stream() + .map(file -> { + IconKey key = iconKeyOf(file); + iconKeys.add(key); + return new ModInfoObject(file, icons, key); + }) + .toList(); + + return new LoadedMods(items, iconKeys); } catch (IOException e) { throw new UncheckedIOException(e); } finally { lock.unlock(); } - }, Schedulers.io()).whenCompleteAsync((list, exception) -> { + }, Schedulers.io()).whenCompleteAsync((loaded, exception) -> { if (this.modManager != modManager) { return; } @@ -167,7 +208,10 @@ private void loadMods(ModManager modManager) { updateSupportedLoaders(modManager); if (exception == null) { - getItems().setAll(list); + // Pruning on the loading thread would drop the icons of the list still displayed. + icons.keySet().retainAll(loaded.iconKeys()); + + getItems().setAll(loaded.items()); } else { LOG.warning("Failed to load mods", exception); getItems().clear(); @@ -600,13 +644,16 @@ public static final class ModInfoObject { private final ItemPropertyAsyncCache iconCache; - ModInfoObject(LocalModFile localModFile) { + ModInfoObject( + LocalModFile localModFile, + ConcurrentMap>> icons, + IconKey iconKey) { this.localModFile = localModFile; this.active = localModFile.activeProperty(); this.modTranslations = ModTranslations.MOD.getMod(localModFile.getId(), localModFile.getName()); - this.iconCache = new ItemPropertyAsyncCache.Soft<>(this, this::loadIcon, this::getDefaultIcon); + this.iconCache = new ItemPropertyAsyncCache.Shared<>(this, icons, iconKey, this::loadIcon, this::getDefaultIcon); } public LocalModFile getModInfo() { @@ -627,6 +674,8 @@ private Image loadIcon() { if (StringUtils.isNotBlank(this.localModFile.getLogoPath())) { iconPaths.add(this.localModFile.getLogoPath()); } + if (iconPaths.isEmpty()) + return getDefaultIcon(); try (FileSystem fs = CompressingUtils.createReadOnlyZipFileSystem(this.localModFile.getFile())) { for (String path : iconPaths) { @@ -641,6 +690,9 @@ private Image loadIcon() { } } catch (Exception e) { LOG.warning("Failed to load mod icons", e); + // Report the failure instead of returning the placeholder, which would be kept for + // as long as the file keeps its size and modification time. + throw new CompletionException(e); } return getDefaultIcon(); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java index 4601ed1ca5d..b79db1bd8bb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/FabricModMetadata.java @@ -20,10 +20,8 @@ import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; import kala.compress.archivers.zip.ZipArchiveEntry; -import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; -import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.tree.ZipFileTree; @@ -60,13 +58,13 @@ public FabricModMetadata(String id, String name, String version, String icon, St this.contact = contact; } - public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { + public static LocalModFile.Metadata fromFile(Path modFile, ZipFileTree tree) throws IOException, JsonParseException { ZipArchiveEntry mcmod = tree.getEntry("fabric.mod.json"); if (mcmod == null) throw new IOException("File " + modFile + " is not a Fabric mod."); FabricModMetadata metadata = JsonUtils.fromNonNullJsonFully(tree.getInputStream(mcmod), FabricModMetadata.class); String authors = metadata.authors == null ? "" : metadata.authors.stream().map(author -> author.name).collect(Collectors.joining(", ")); - return new LocalModFile(modManager, modManager.getLocalMod(metadata.id, ModLoaderType.FABRIC), modFile, metadata.name, new LocalAddonFile.Description(metadata.description), + return new LocalModFile.Metadata(metadata.id, ModLoaderType.FABRIC, metadata.name, metadata.description, authors, metadata.version, "", metadata.contact != null ? metadata.contact.getOrDefault("homepage", "") : "", metadata.icon); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java index 6b7ff0085b4..c78421697af 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeNewModMetadata.java @@ -25,10 +25,8 @@ import com.google.gson.JsonPrimitive; import com.google.gson.annotations.JsonAdapter; import kala.compress.archivers.zip.ZipArchiveEntry; -import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; -import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonSerializable; @@ -177,43 +175,42 @@ public String deserialize(JsonElement authors, Type type, JsonDeserializationCon } } - public static LocalModFile fromForgeFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException { - return fromFile(modManager, modFile, tree, ModLoaderType.FORGE); + public static LocalModFile.Metadata fromForgeFile(Path modFile, ZipFileTree tree) throws IOException { + return fromFile(modFile, tree, ModLoaderType.FORGE); } - public static LocalModFile fromNeoForgeFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException { - return fromFile(modManager, modFile, tree, ModLoaderType.NEO_FORGE); + public static LocalModFile.Metadata fromNeoForgeFile(Path modFile, ZipFileTree tree) throws IOException { + return fromFile(modFile, tree, ModLoaderType.NEO_FORGE); } - private static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException { + private static LocalModFile.Metadata fromFile(Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException { if (modLoaderType != ModLoaderType.FORGE && modLoaderType != ModLoaderType.NEO_FORGE) { throw new IOException("Invalid mod loader: " + modLoaderType); } if (modLoaderType == ModLoaderType.NEO_FORGE) { try { - return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modManager, modFile, tree); + return fromFile0("META-INF/neoforge.mods.toml", modLoaderType, modFile, tree); } catch (Exception ignored) { } } try { - return fromFile0("META-INF/mods.toml", modLoaderType, modManager, modFile, tree); + return fromFile0("META-INF/mods.toml", modLoaderType, modFile, tree); } catch (Exception ignored) { } try { - return fromEmbeddedMod(modManager, modFile, tree, modLoaderType); + return fromEmbeddedMod(modFile, tree, modLoaderType); } catch (Exception ignored) { } throw new IOException("File " + modFile + " is not a Forge 1.13+ or NeoForge mod."); } - private static LocalModFile fromFile0( + private static LocalModFile.Metadata fromFile0( String tomlPath, ModLoaderType modLoaderType, - ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { ZipArchiveEntry modToml = tree.getEntry(tomlPath); @@ -244,13 +241,13 @@ private static LocalModFile fromFile0( String logoPath = StringUtils.isNotBlank(mod.getLogoFile()) ? mod.getLogoFile() : metadata.getLogoFile(); - return new LocalModFile(modManager, modManager.getLocalMod(mod.getModId(), type), modFile, mod.getDisplayName(), new LocalAddonFile.Description(mod.getDescription()), + return new LocalModFile.Metadata(mod.getModId(), type, mod.getDisplayName(), mod.getDescription(), mod.getAuthors(), jarVersion == null ? mod.getVersion() : mod.getVersion().replace("${file.jarVersion}", jarVersion), "", mod.getDisplayURL(), logoPath); } - private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException { + private static LocalModFile.Metadata fromEmbeddedMod(Path modFile, ZipFileTree tree, ModLoaderType modLoaderType) throws IOException { ZipArchiveEntry manifestFile = tree.getEntry("META-INF/MANIFEST.MF"); if (manifestFile == null) throw new IOException("Missing MANIFEST.MF in file " + modFile); @@ -300,7 +297,7 @@ private static LocalModFile fromEmbeddedMod(ModManager modManager, Path modFile, for (ZipArchiveEntry embeddedModFile : embeddedModFiles) { tree.extractTo(embeddedModFile, tempFile); try (ZipFileTree embeddedTree = CompressingUtils.openZipTree(tempFile)) { - return fromFile(modManager, modFile, embeddedTree, modLoaderType); + return fromFile(modFile, embeddedTree, modLoaderType); } catch (Exception ignored) { } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeOldModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeOldModMetadata.java index 81f358c94e6..8d523fb5af8 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeOldModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/ForgeOldModMetadata.java @@ -22,10 +22,8 @@ import com.google.gson.stream.JsonReader; import com.google.gson.stream.JsonToken; import kala.compress.archivers.zip.ZipArchiveEntry; -import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; -import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -124,7 +122,7 @@ public String[] getAuthors() { return authors; } - public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { + public static LocalModFile.Metadata fromFile(Path modFile, ZipFileTree tree) throws IOException, JsonParseException { ZipArchiveEntry mcmod = tree.getEntry("mcmod.info"); if (mcmod == null) throw new IOException("File " + modFile + " is not a Forge mod."); @@ -157,7 +155,7 @@ else if (firstToken == JsonToken.BEGIN_OBJECT) { authors = String.join(", ", metadata.getAuthorList()); if (StringUtils.isBlank(authors)) authors = metadata.getCredits(); - return new LocalModFile(modManager, modManager.getLocalMod(metadata.getModId(), ModLoaderType.FORGE), modFile, metadata.getName(), new LocalAddonFile.Description(metadata.getDescription()), + return new LocalModFile.Metadata(metadata.getModId(), ModLoaderType.FORGE, metadata.getName(), metadata.getDescription(), authors, metadata.getVersion(), metadata.getGameVersion(), StringUtils.isBlank(metadata.getUrl()) ? metadata.getUpdateUrl() : metadata.url, metadata.getLogoFile()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/LiteModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/LiteModMetadata.java index aea8c4fab2e..1395db2b02c 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/LiteModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/LiteModMetadata.java @@ -19,10 +19,8 @@ import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveEntry; -import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; -import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.tree.ZipFileTree; @@ -110,14 +108,14 @@ public String getUpdateURI() { return updateURI; } - public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { + public static LocalModFile.Metadata fromFile(Path modFile, ZipFileTree tree) throws IOException, JsonParseException { ZipArchiveEntry entry = tree.getEntry("litemod.json"); if (entry == null) throw new IOException("File " + modFile + " is not a LiteLoader mod."); LiteModMetadata metadata = JsonUtils.fromJsonFully(tree.getInputStream(entry), LiteModMetadata.class); if (metadata == null) throw new IOException("Mod " + modFile + " `litemod.json` is malformed."); - return new LocalModFile(modManager, modManager.getLocalMod(metadata.getName(), ModLoaderType.LITE_LOADER), modFile, metadata.getName(), new LocalAddonFile.Description(metadata.getDescription()), metadata.getAuthor(), + return new LocalModFile.Metadata(metadata.getName(), ModLoaderType.LITE_LOADER, metadata.getName(), metadata.getDescription(), metadata.getAuthor(), metadata.getVersion(), metadata.getGameVersion(), metadata.getUpdateURI(), ""); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java index 02e31ed7269..6a951b01d85 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/meta/QuiltModMetadata.java @@ -20,10 +20,8 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveEntry; -import org.jackhuang.hmcl.addon.LocalAddonFile; import org.jackhuang.hmcl.addon.mod.LocalModFile; import org.jackhuang.hmcl.addon.mod.ModLoaderType; -import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.util.Immutable; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.tree.ZipFileTree; @@ -71,7 +69,7 @@ public QuiltModMetadata(int schemaVersion, QuiltLoader quiltLoader) { this.quilt_loader = quiltLoader; } - public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { + public static LocalModFile.Metadata fromFile(Path modFile, ZipFileTree tree) throws IOException, JsonParseException { ZipArchiveEntry path = tree.getEntry("quilt.mod.json"); if (path == null) { throw new IOException("File " + modFile + " is not a Quilt mod."); @@ -82,12 +80,11 @@ public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFile throw new IOException("File " + modFile + " is not a supported Quilt mod."); } - return new LocalModFile( - modManager, - modManager.getLocalMod(root.quilt_loader.id, ModLoaderType.QUILT), - modFile, + return new LocalModFile.Metadata( + root.quilt_loader.id, + ModLoaderType.QUILT, root.quilt_loader.metadata.name, - new LocalAddonFile.Description(root.quilt_loader.metadata.description), + root.quilt_loader.metadata.description, root.quilt_loader.metadata.contributors.entrySet().stream().map(entry -> String.format("%s (%s)", entry.getKey(), entry.getValue().getAsJsonPrimitive().getAsString())).collect(Collectors.joining(", ")), root.quilt_loader.version, "", diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java index ab364826864..9818a027b95 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/mod/LocalModFile.java @@ -25,6 +25,8 @@ import org.jackhuang.hmcl.addon.RemoteAddonRepository; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.file.Files; @@ -39,6 +41,24 @@ */ public final class LocalModFile extends LocalAddonFile implements Comparable { + /// The metadata parsed from a mod file. + /// + /// The readers in `org.jackhuang.hmcl.addon.meta` return this instead of a [LocalModFile]: + /// constructing one reads and registers manager state, so its result cannot outlive a refresh. + @NotNullByDefault + public record Metadata( + String modId, + ModLoaderType loaderType, + @Nullable String name, + @Nullable String description, + @Nullable String authors, + @Nullable String version, + @Nullable String gameVersion, + @Nullable String url, + @Nullable String logoPath + ) { + } + private Path file; private final ModManager modManager; private final LocalMod mod; @@ -52,10 +72,6 @@ public final class LocalModFile extends LocalAddonFile implements Comparable { @FunctionalInterface private interface ModMetadataReader { - LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException; + LocalModFile.Metadata fromFile(Path modFile, ZipFileTree tree) throws IOException, JsonParseException; + } + + private record ParsedMod(Path file, LocalModFile.Metadata metadata) { + } + + private record CacheEntry(long size, long lastModified, LocalModFile.Metadata metadata) { } private static final Map>> READERS; @@ -66,6 +75,12 @@ private interface ModMetadataReader { } private final HashMap, LocalMod> localMods = new HashMap<>(); + + /// Metadata of every file seen by the last refresh, keyed by file. An entry is reused while the + /// file keeps its size and modification time, so refreshing an unchanged mods directory reads + /// no archive at all. + private final ConcurrentHashMap parseCache = new ConcurrentHashMap<>(); + private GameComponentAnalyzer analyzer; private boolean loaded = false; @@ -105,22 +120,92 @@ public boolean hasMod(String modId, ModLoaderType modLoaderType) { } } - private void addModInfo(Path file) { - String fileName = StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION); - String extension = fileName.substring(fileName.lastIndexOf(".") + 1); + /// Returns the extension a file is looked up by, or an empty string when its name has none. + private static String modExtensionOf(Path file) { + return FileUtils.getExtension(getLocalAddonName(file)); + } + + private List collectCandidates(boolean supportSubfolders) throws IOException { + Path directory = getDirectory(); + if (!Files.isDirectory(directory)) + return List.of(); + + var result = new ArrayList(); + try (DirectoryStream modsDirectoryStream = Files.newDirectoryStream(directory)) { + for (Path subitem : modsDirectoryStream) { + if (supportSubfolders && Files.isDirectory(subitem) && !".connector".equalsIgnoreCase(subitem.getFileName().toString())) { + try (DirectoryStream subitemDirectoryStream = Files.newDirectoryStream(subitem)) { + for (Path subsubitem : subitemDirectoryStream) { + result.add(subsubitem); + } + } + } else { + result.add(subitem); + } + } + } + return result; + } + + private List parseAll(List candidates, Set modLoaderTypes) { + var result = new ArrayList(candidates.size()); + for (Path file : candidates) { + LocalModFile.Metadata metadata = parseFile(file, modLoaderTypes); + if (metadata != null) + result.add(new ParsedMod(file, metadata)); + } + return result; + } - List> readersMap = READERS.get(extension); - if (readersMap == null) { + /// Parses one candidate file, or returns `null` when it is not a mod. + private @Nullable LocalModFile.Metadata parseFile(Path file, Set modLoaderTypes) { + String extension = modExtensionOf(file); + List> readers = READERS.get(extension); + if (readers == null) { // Is not a mod file. - return; + return null; + } + + BasicFileAttributes attributes; + try { + attributes = Files.readAttributes(file, BasicFileAttributes.class); + } catch (IOException e) { + // The file disappeared between listing the directory and reading it. + return null; } - Set modLoaderTypes = instance.getModLoaders(); + if (!attributes.isRegularFile()) + return null; + long size = attributes.size(); + long lastModified = attributes.lastModifiedTime().toMillis(); + + CacheEntry cached = parseCache.get(file); + if (cached != null && cached.size() == size && cached.lastModified() == lastModified) + return cached.metadata(); + + LocalModFile.Metadata metadata = readMetadata(file, readers, modLoaderTypes); + if (metadata == null) { + // A file that could not be read still shows up in the list as an unrecognized mod, but + // its state is not cached: the failure may be transient, so a refresh should retry it. + String fileNameWithoutExtension = FileUtils.getNameWithoutExtension(file); + return new LocalModFile.Metadata(fileNameWithoutExtension, ModLoaderType.UNKNOWN, fileNameWithoutExtension, + "litemod".equals(extension) ? "LiteLoader Mod" : "", + "", "", "", "", ""); + } + + parseCache.put(file, new CacheEntry(size, lastModified, metadata)); + return metadata; + } + + /// Parses the metadata of one mod file, or returns `null` when no reader understood it. + private static @Nullable LocalModFile.Metadata readMetadata( + Path file, + List> readers, + Set modLoaderTypes) { var supportedReaders = new ArrayList(); var unsupportedReaders = new ArrayList(); - - for (Pair reader : readersMap) { + for (Pair reader : readers) { if (modLoaderTypes.contains(reader.getValue())) { supportedReaders.add(reader.getKey()); } else { @@ -128,23 +213,23 @@ private void addModInfo(Path file) { } } - LocalModFile modInfo = null; + LocalModFile.Metadata metadata = null; List exceptions = new ArrayList<>(); try (ZipFileTree tree = CompressingUtils.openZipTree(file)) { for (ModMetadataReader reader : supportedReaders) { try { - modInfo = reader.fromFile(this, file, tree); + metadata = reader.fromFile(file, tree); break; } catch (Exception e) { exceptions.add(e); } } - if (modInfo == null) { + if (metadata == null) { for (ModMetadataReader reader : unsupportedReaders) { try { - modInfo = reader.fromFile(this, file, tree); + metadata = reader.fromFile(file, tree); break; } catch (Exception ignored) { } @@ -154,22 +239,29 @@ private void addModInfo(Path file) { LOG.warning("Failed to open mod file " + file, e); } - if (modInfo == null) { - Exception exception = new Exception("Failed to read mod metadata"); - for (Exception e : exceptions) { - exception.addSuppressed(e); - } - LOG.warning("Failed to read mod metadata", exception); - - String fileNameWithoutExtension = FileUtils.getNameWithoutExtension(file); + if (metadata != null) + return metadata; - modInfo = new LocalModFile(this, - getLocalMod(fileNameWithoutExtension, ModLoaderType.UNKNOWN), - file, - fileNameWithoutExtension, - new LocalAddonFile.Description("litemod".equals(extension) ? "LiteLoader Mod" : "") - ); + Exception exception = new Exception("Failed to read mod metadata"); + for (Exception e : exceptions) { + exception.addSuppressed(e); } + LOG.warning("Failed to read mod metadata", exception); + return null; + } + + private void addModInfo(Path file, LocalModFile.Metadata metadata) { + LocalModFile modInfo = new LocalModFile(this, + getLocalMod(metadata.modId(), metadata.loaderType()), + file, + metadata.name(), + // Readers take the description from JSON, where it may be absent or null; Description rejects null. + new LocalAddonFile.Description(Objects.requireNonNullElse(metadata.description(), "")), + metadata.authors(), + metadata.version(), + metadata.gameVersion(), + metadata.url(), + metadata.logoPath()); if (!modInfo.isOld()) { localFiles.add(modInfo); @@ -180,31 +272,26 @@ private void addModInfo(Path file) { public void refresh() throws IOException { lock.lock(); try { - localFiles.clear(); - localMods.clear(); - - analyzer = instance.getAnalyzer(); + GameComponentAnalyzer analyzer = instance.getAnalyzer(); boolean supportSubfolders = analyzer.has(GameComponentType.FORGE) || analyzer.has(GameComponentType.QUILT) || analyzer.has(GameComponentType.CLEANROOM) || analyzer.has(GameComponentType.LITELOADER); - if (Files.isDirectory(getDirectory())) { - try (DirectoryStream modsDirectoryStream = Files.newDirectoryStream(getDirectory())) { - for (Path subitem : modsDirectoryStream) { - if (supportSubfolders && Files.isDirectory(subitem) && !".connector".equalsIgnoreCase(subitem.getFileName().toString())) { - try (DirectoryStream subitemDirectoryStream = Files.newDirectoryStream(subitem)) { - for (Path subsubitem : subitemDirectoryStream) { - addModInfo(subsubitem); - } - } - } else { - addModInfo(subitem); - } - } - } + List candidates = collectCandidates(supportSubfolders); + List parsed = parseAll(candidates, instance.getModLoaders()); + + localFiles.clear(); + localMods.clear(); + parseCache.keySet().retainAll(new HashSet<>(candidates)); + + this.analyzer = analyzer; + + for (ParsedMod mod : parsed) { + addModInfo(mod.file(), mod.metadata()); } + loaded = true; } finally { lock.unlock(); @@ -242,7 +329,10 @@ public void addMod(Path file) throws IOException { Path newFile = modsDirectory.resolve(file.getFileName()); FileUtils.copyFile(file, newFile); - addModInfo(newFile); + LocalModFile.Metadata metadata = parseFile(newFile, instance.getModLoaders()); + if (metadata != null) { + addModInfo(newFile, metadata); + } } finally { lock.unlock(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/ItemPropertyAsyncCache.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/ItemPropertyAsyncCache.java index 7ee836abaa6..a74ae94f4ae 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/ItemPropertyAsyncCache.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/javafx/ItemPropertyAsyncCache.java @@ -26,6 +26,7 @@ import java.lang.ref.WeakReference; import java.util.Objects; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentMap; import java.util.function.Supplier; /// Cache for a property of an item of [javafx.scene.control.ListCell]. @@ -130,4 +131,47 @@ protected void setFuture(@NotNull CompletableFuture future) { this.cache = new SoftReference<>(future); } } + + /// Implementation of [ItemPropertyAsyncCache] whose values outlive the item that asked for them. + /// + /// A cell item is rebuilt whenever its list is reloaded, so the caller keeps the values + /// instead, keyed by what they depend on. + /// + /// @param {@inheritDoc} + /// @param {@inheritDoc} + public static final class Shared extends Base { + + private final ConcurrentMap>> shared; + private final Object key; + + /// @param shared the map holding the values, owned by the caller + /// @param key the key identifying the value; two items asking for the same key share it + public Shared( + B bean, + ConcurrentMap>> shared, + Object key, + Supplier valueSupplier, + @Nullable Supplier defaultSupplier) { + super(bean, valueSupplier, defaultSupplier); + this.shared = Objects.requireNonNull(shared); + this.key = Objects.requireNonNull(key); + } + + @Override + protected @Nullable CompletableFuture getFuture() { + SoftReference<@Nullable CompletableFuture> reference = shared.get(key); + CompletableFuture future = reference != null ? reference.get() : null; + if (future != null && future.isCompletedExceptionally()) { + // A failed load must not stick: forget it, so that the next load retries it. + shared.remove(key, reference); + return null; + } + return future; + } + + @Override + protected void setFuture(@NotNull CompletableFuture future) { + shared.put(key, new SoftReference<>(future)); + } + } }