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)); + } + } }