Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -93,12 +98,37 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> 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<Object, SoftReference<@Nullable CompletableFuture<Image>>> icons = new ConcurrentHashMap<>();

private ModManager modManager;
private @Nullable HMCLGameInstance gameInstance;
private String gameVersion;

final EnumSet<ModLoaderType> 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<ModInfoObject> items, Set<Object> iconKeys) {
}

/// Creates a mod list that reloads when `instanceContext` changes.
///
/// @param instanceContext the parent page's instance property
Expand Down Expand Up @@ -153,21 +183,35 @@ private void loadMods(ModManager modManager) {
lock.lock();
try {
modManager.refresh();
return modManager.getLocalFiles().stream().map(ModInfoObject::new).toList();

List<LocalModFile> files = modManager.getLocalFiles();
Set<Object> iconKeys = new HashSet<>(files.size() * 2);
List<ModInfoObject> 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;
}

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();
Expand Down Expand Up @@ -600,13 +644,16 @@ public static final class ModInfoObject {

private final ItemPropertyAsyncCache<Image, ModInfoObject> iconCache;

ModInfoObject(LocalModFile localModFile) {
ModInfoObject(
LocalModFile localModFile,
ConcurrentMap<Object, SoftReference<@Nullable CompletableFuture<Image>>> 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() {
Expand All @@ -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) {
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.");
Expand Down Expand Up @@ -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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(), "");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.");
Expand All @@ -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,
"",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -39,6 +41,24 @@
*/
public final class LocalModFile extends LocalAddonFile implements Comparable<LocalModFile> {

/// 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;
Expand All @@ -52,10 +72,6 @@ public final class LocalModFile extends LocalAddonFile implements Comparable<Loc
private final String logoPath;
private final BooleanProperty activeProperty;

public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description) {
this(modManager, mod, file, name, description, "", "", "", "", "");
}

public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description, String authors, String version, String gameVersion, String url, String logoPath) {
super();
this.modManager = modManager;
Expand Down
Loading