From 7e633f143a1a95d81c3fbb002ba581a2adf7d357 Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sat, 22 Aug 2026 11:22:26 +0800 Subject: [PATCH 01/17] preparation --- .../hmcl/ui/instances/ModListPage.java | 6 ++-- .../jackhuang/hmcl/addon/mod/ModManager.java | 30 +++++++------------ 2 files changed, 13 insertions(+), 23 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 0d4662ed59..9e515fd322 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 @@ -75,7 +75,7 @@ public ModListPage(ObservableValue instance LOG.warning("Unable to parse mod file " + it, e); } }); - loadMods(modManager); + refresh(); }); listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { @@ -215,7 +215,7 @@ public void add() { if (!failed.isEmpty()) prompt.add(i18n("mods.add.failed", String.join(", ", failed))); Controllers.dialog(String.join("\n", prompt), i18n("mods.add")); - loadMods(modManager); + refresh(); }).start(); } @@ -225,7 +225,7 @@ void removeSelected(ObservableList selectedItems) .filter(Objects::nonNull) .map(ModListPageSkin.ModInfoObject::getModInfo) .toArray(LocalModFile[]::new)); - loadMods(modManager); + refresh(); } catch (IOException ignore) { // Fail to remove mods if the game is running or the mod is absent. } 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 e89abc3e38..2db0c660ae 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 @@ -117,14 +117,14 @@ private void addModInfo(Path file) { Set modLoaderTypes = instance.getModLoaders(); - var supportedReaders = new ArrayList(); - var unsupportedReaders = new ArrayList(); + var preferredReaders = new ArrayList(); + var alternativeReaders = new ArrayList(); for (Pair reader : readersMap) { if (modLoaderTypes.contains(reader.getValue())) { - supportedReaders.add(reader.getKey()); + preferredReaders.add(reader.getKey()); } else { - unsupportedReaders.add(reader.getKey()); + alternativeReaders.add(reader.getKey()); } } @@ -132,7 +132,7 @@ private void addModInfo(Path file) { List exceptions = new ArrayList<>(); try (ZipFileTree tree = CompressingUtils.openZipTree(file)) { - for (ModMetadataReader reader : supportedReaders) { + for (ModMetadataReader reader : preferredReaders) { try { modInfo = reader.fromFile(this, file, tree); break; @@ -142,7 +142,7 @@ private void addModInfo(Path file) { } if (modInfo == null) { - for (ModMetadataReader reader : unsupportedReaders) { + for (ModMetadataReader reader : alternativeReaders) { try { modInfo = reader.fromFile(this, file, tree); break; @@ -231,21 +231,11 @@ public void addMod(Path file) throws IOException { if (!isFileNameMod(file)) throw new IllegalArgumentException("File " + file + " is not a valid mod file."); - lock.lock(); - try { - if (!loaded) - refresh(); - - Path modsDirectory = getDirectory(); - Files.createDirectories(modsDirectory); + Path modsDirectory = getDirectory(); + Files.createDirectories(modsDirectory); - Path newFile = modsDirectory.resolve(file.getFileName()); - FileUtils.copyFile(file, newFile); - - addModInfo(newFile); - } finally { - lock.unlock(); - } + Path newFile = modsDirectory.resolve(file.getFileName()); + FileUtils.copyFile(file, newFile); } public void removeMods(LocalModFile... localModFiles) throws IOException { From daaff935d88bb4cb809266caa22b65445d087bf6 Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sat, 22 Aug 2026 16:33:00 +0800 Subject: [PATCH 02/17] impl --- .../game/LocalizedRemoteAddonRepository.java | 5 ++ .../hmcl/ui/instances/DownloadPage.java | 37 ++++++++-- .../ui/instances/ResourcePackListPage.java | 2 +- .../resources/assets/lang/I18N.properties | 1 + .../resources/assets/lang/I18N_zh.properties | 1 + .../assets/lang/I18N_zh_CN.properties | 1 + .../jackhuang/hmcl/addon/LocalAddonFile.java | 24 ++++++- .../hmcl/addon/LocalAddonManager.java | 67 ++++++++++++++++--- .../org/jackhuang/hmcl/addon/RemoteAddon.java | 8 +-- .../hmcl/addon/RemoteAddonRepository.java | 3 + .../jackhuang/hmcl/addon/mod/ModManager.java | 27 ++------ .../CurseForgeRemoteAddonRepository.java | 25 +++++-- .../ModrinthRemoteAddonRepository.java | 33 +++++++-- .../resourcepack/ResourcePackManager.java | 42 +++--------- .../hmcl/auth/offline/YggdrasilServer.java | 3 +- .../hmcl/game/DefaultGameInstance.java | 18 +++++ .../java/org/jackhuang/hmcl/util/Lang.java | 5 -- 17 files changed, 207 insertions(+), 95 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteAddonRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteAddonRepository.java index dc54158d23..1d0e1f2845 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteAddonRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteAddonRepository.java @@ -148,6 +148,11 @@ public Stream getRemoteVersionsById(DownloadProvider downlo return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id); } + @Override + public boolean hasRemoteVersionWithHashes(DownloadProvider downloadProvider, String id, Set hashes) throws IOException { + return getBackedRemoteModRepository().hasRemoteVersionWithHashes(downloadProvider, id, hashes); + } + @Override public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException { return getBackedRemoteModRepository().getAddonChangelog(downloadProvider, addonId, versionId); 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 8f32d07cc4..5271d282a5 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 @@ -29,6 +29,7 @@ import javafx.scene.control.*; import javafx.scene.layout.*; import javafx.stage.FileChooser; +import org.jackhuang.hmcl.addon.LocalAddonManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.game.*; import org.jackhuang.hmcl.addon.mod.ModLoaderType; @@ -48,6 +49,7 @@ import org.jackhuang.hmcl.util.versioning.GameVersionNumber; import org.jetbrains.annotations.Nullable; +import java.io.IOException; import java.nio.file.Path; import java.util.*; import java.util.stream.Stream; @@ -127,6 +129,10 @@ public RemoteAddon getAddon() { return addon; } + public DownloadProvider getDownloadProvider() { + return page.getDownloadProvider(); + } + public HMCLGameInstance.Optional getInstanceOptional() { return instanceReference; } @@ -371,7 +377,7 @@ private static final class DependencyAddonItem extends LineButton { public final RemoteAddon addon; - DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference) { + DependencyAddonItem(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference, boolean installed) { this.addon = addon; HBox pane = new HBox(8); @@ -401,6 +407,8 @@ private static final class DependencyAddonItem extends LineButton { ModTranslations.Mod mod = ModTranslations.getTranslationsByAddonType(type).getModByCurseForgeId(addon.slug()); content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.title()); content.setSubtitle(addon.description()); + if (installed) + content.addTag(i18n("addon.dependency.installed")); for (String category : addon.categories()) { if (page.shouldDisplayCategory(category)) content.addTag(page.getLocalizedCategory(category, null)); @@ -526,7 +534,7 @@ public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage s box.getChildren().setAll(addonItem); JFXHyperlink changelogButton = new JFXHyperlink(i18n("addon.changelog")); - changelogButton.setOnAction(__ -> Controllers.dialog(new AddonChangelog(version, selfPage.repository, selfPage.page.getDownloadProvider()))); + changelogButton.setOnAction(__ -> Controllers.dialog(new AddonChangelog(version, selfPage.repository, selfPage.getDownloadProvider()))); JFXHyperlink versionPageBtn = new JFXHyperlink(i18n("mods.url")); versionPageBtn.setDisable(true); @@ -608,13 +616,32 @@ private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage dependencies.put(dependency.getType(), Pair.pair(title, list)); } - queue.add(Task.supplyAsync(Schedulers.io(), () -> dependency.load(selfPage.page.getDownloadProvider())) + queue.add(Task.supplyAsync(Schedulers.io(), () -> { + var addon = dependency.load(selfPage.getDownloadProvider()); + if (addon == RemoteAddon.BROKEN) return Pair.pair(addon, false); + HMCLGameInstance instance = selfPage.getInstanceOptional().instance(); + RemoteAddon.Source source = addon.source(); + RemoteAddon.Type type = addon.type(); + LocalAddonManager manager = type != null && instance != null ? instance.getManagerForType(type) : null; + if (source != null && manager != null) { + try { + return Pair.pair(addon, source.getCommonRepo().hasRemoteVersionWithHashes( + selfPage.getDownloadProvider(), + addon.id(), + manager.getHashes(source) + )); + } catch (IOException e) { + LOG.warning("Failed to check hashes of addons", e); + } + } + return Pair.pair(addon, false); + }) .setSignificance(Task.TaskSignificance.MINOR) .thenAcceptAsync(Schedulers.javafx(), dep -> { - if (dep == RemoteAddon.BROKEN) { + if (dep.key() == RemoteAddon.BROKEN) { return; } - DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.instanceReference); + DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep.key(), selfPage.instanceReference, dep.value()); dependencies.get(dependency.getType()).value().add(dependencyAddonItem); }) .setSignificance(Task.TaskSignificance.MINOR)); 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 c97d5cd269..0e088eaf52 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 @@ -173,7 +173,7 @@ public void addFiles(List files) { Task.runAsync(() -> { for (Path file : files) { try { - resourcePackManager.importResourcePack(file); + resourcePackManager.addResourcePack(file); } catch (Exception e) { LOG.warning("Failed to add resource pack", e); failures.add(file); diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 0e4aa87042..51374faf27 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -186,6 +186,7 @@ addon.dependency.required=Required Dependencies (Must be downloaded separately. addon.dependency.tool=Required Dependencies (Must be downloaded separately. Missing may prevent the game from launching) addon.dependency.include=Built-in Dependencies (Already packaged in the addon file by the author. No need to download separately) addon.dependency.incompatible=Incompatible Addons (Installing these addons at the same time will prevent the game from launching) +addon.dependency.installed=Installed addon.dependency.broken=Broken Dependencies (This addon existed before, but it does not exist anymore. Try using another download source.) addon.download.recommend=Recommended Version for Minecraft %1s addon.download.title.release=Minecraft %s diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 75cff38b19..f310f2bd86 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -184,6 +184,7 @@ addon.dependency.required=必需相依內容 (必須單獨下載,缺少可能 addon.dependency.tool=相依庫 (必須單獨下載,缺少可能會導致遊戲無法啟動) addon.dependency.include=內建相依內容 (作者已經打包在檔案中,無需單獨下載) addon.dependency.incompatible=不相容附加內容 (與正在下載的檔案同時安裝會導致遊戲無法啟動) +addon.dependency.installed=已安裝 addon.dependency.broken=損壞的相依內容 (該相依內容曾經存在於附加內容下載源中,但現在已被刪除,請嘗試其他下載源) addon.download.recommend=推薦版本 - Minecraft %1s addon.download.title.release=Minecraft %s diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index a75db02bf1..c9430064bb 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -186,6 +186,7 @@ addon.dependency.required=必需的前置内容 (必须另外下载,缺失可 addon.dependency.tool=前置库 (必须另外下载,缺失可能会导致游戏无法启动) addon.dependency.include=内置的前置内容 (已经由作者打包在文件中,无需另外下载) addon.dependency.incompatible=不兼容的附加内容 (同时与正在下载的文件安装会导致游戏无法启动) +addon.dependency.installed=已安装 addon.dependency.broken=损坏的前置内容 (该前置内容曾经在该附加内容下载源上存在过,但现在被删除了,换个下载源试试吧) addon.download.recommend=推荐版本 - Minecraft %1s addon.download.title.release=Minecraft %s diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java index 0537e4a450..eaf5cc1c87 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java @@ -17,7 +17,9 @@ */ package org.jackhuang.hmcl.addon; +import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.util.DigestUtils; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.NotNullByDefault; @@ -27,6 +29,7 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; +import java.util.Locale; import java.util.Objects; import java.util.stream.Collectors; @@ -42,7 +45,7 @@ protected LocalAddonFile() { public abstract String getFileName(); public boolean isDisabled() { - return FileUtils.getName(getFile()).endsWith(LocalAddonManager.DISABLED_EXTENSION); + return FileUtils.getName(getFile()).toLowerCase(Locale.ROOT).endsWith(LocalAddonManager.DISABLED_EXTENSION); } public abstract void markDisabled() throws IOException; @@ -53,6 +56,25 @@ public boolean isDisabled() { public abstract void delete() throws IOException; + private long cfFingerprint = -1L; + private String sha1 = null; + + /// Calculates file fingerprint for CurseForge. + /// + /// @return file fingerprint used by CurseForge, non-negative + public long calculateFingerprintCurseForge() throws IOException { + if (cfFingerprint < 0) cfFingerprint = CurseForgeRemoteAddonRepository.calculateFingerprint(getFile()); + return cfFingerprint; + } + + /// Calculates SHA-1 hash for Modrinth. + /// + /// @return SHA-1 hash used by Modrinth + public String calculateSha1Modrinth() throws IOException { + if (sha1 == null) sha1 = DigestUtils.digestToString("SHA-1", getFile()); + return sha1; + } + @Nullable public AddonUpdate checkUpdates(DownloadProvider downloadProvider, String gameVersion, RemoteAddon.Source source) throws IOException { return null; 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 8a81e1fe35..3ba62540ff 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java @@ -27,11 +27,9 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; -import java.util.Comparator; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; +import java.util.*; import java.util.concurrent.locks.ReentrantLock; +import java.util.stream.Collectors; /// Manages local addon files for a single [DefaultGameInstance] snapshot member. /// @@ -58,12 +56,14 @@ public static String getLocalAddonName(Path file) { /// Lock guarding [#localFiles] and subclass mutable state. protected final ReentrantLock lock = new ReentrantLock(); - /// Loaded local addon files for the bound instance. + /// Loaded local addon files for the bound instance, should be updated only in [#refresh()] protected final Set<@NotNull T> localFiles = new LinkedHashSet<>(); /// The snapshot member this manager serves. protected final DefaultGameInstance instance; + protected boolean loaded = false; + /// Creates a manager bound to the given instance. /// /// @param instance the snapshot member whose addon directory this manager operates on @@ -85,6 +85,7 @@ public DefaultGameInstance getInstance() { /// Reloads local addon files from disk into [#localFiles]. /// + /// @implSpec This should be the only place that updates [#localFiles]. /// @throws IOException if the directory cannot be listed or a required instance path cannot be read public abstract void refresh() throws IOException; @@ -100,31 +101,75 @@ public DefaultGameInstance getInstance() { public @Unmodifiable List getLocalFiles() throws IOException { lock.lock(); try { + if (!loaded) + refresh(); return localFiles.stream().sorted(getComparator()).toList(); } finally { lock.unlock(); } } + /// @return hashes for finding versions in the given remote addon source + public Set getHashes(RemoteAddon.Source source) throws IOException { + return switch (source) { + case MODRINTH -> getModrinthSha1Hashes(); + case CURSEFORGE -> getCurseForgeFingerprints(); + }; + } + + private Set getCurseForgeFingerprints() throws IOException { + lock.lock(); + try { + if (!loaded) + refresh(); + return localFiles.parallelStream().filter(localAddonFile -> !localAddonFile.isDisabled()).map(localAddonFile -> { + try { + return localAddonFile.calculateFingerprintCurseForge(); + } catch (IOException e) { + return -1L; + } + }).filter(l -> l >= 0).collect(Collectors.toSet()); + } finally { + lock.unlock(); + } + } + + private Set getModrinthSha1Hashes() throws IOException { + lock.lock(); + try { + if (!loaded) + refresh(); + return localFiles.parallelStream().filter(localAddonFile -> !localAddonFile.isDisabled()).map(localAddonFile -> { + try { + return localAddonFile.calculateSha1Modrinth(); + } catch (IOException e) { + return null; + } + }).filter(Objects::nonNull).collect(Collectors.toSet()); + } finally { + lock.unlock(); + } + } + /// 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 addonFile 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 { + public Path setOld(T addonFile, boolean old) throws IOException { lock.lock(); try { Path newPath; if (old) { - newPath = backupFile(modFile.getFile()); - localFiles.remove(modFile); + newPath = backupFile(addonFile.getFile()); + localFiles.remove(addonFile); } else { - newPath = restoreFile(modFile.getFile()); - localFiles.add(modFile); + newPath = restoreFile(addonFile.getFile()); + localFiles.add(addonFile); } return newPath; } finally { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java index 41acefb921..02c3ed12f3 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java @@ -31,10 +31,10 @@ import java.util.Map; import java.util.stream.Stream; -public record RemoteAddon(String slug, String author, String title, String description, List categories, - String pageUrl, String iconUrl, IAddon data, @Nullable Type type) { +public record RemoteAddon(String id, String slug, String author, String title, String description, List categories, + String pageUrl, String iconUrl, IAddon data, @Nullable Type type, @Nullable Source source) { - public static final RemoteAddon BROKEN = new RemoteAddon("", "", "RemoteAddon.BROKEN", "", Collections.emptyList(), "", "", new IAddon() { + public static final RemoteAddon BROKEN = new RemoteAddon("", "", "", "RemoteAddon.BROKEN", "", Collections.emptyList(), "", "", new IAddon() { @Override public List loadDependencies(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException { throw new IOException(); @@ -44,7 +44,7 @@ public List loadDependencies(RemoteAddonRepository repo, DownloadPr public Stream loadVersions(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException { throw new IOException(); } - }, Type.MOD); + }, null, null); public enum VersionType { Release, diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddonRepository.java index 9c850c4b9f..cc19de1601 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddonRepository.java @@ -25,6 +25,7 @@ import java.nio.file.Path; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.stream.Stream; public interface RemoteAddonRepository { @@ -96,6 +97,8 @@ default RemoteAddon resolveDependency(DownloadProvider downloadProvider, String Stream getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException; + boolean hasRemoteVersionWithHashes(DownloadProvider downloadProvider, String id, Set hashes) throws IOException; + @Nullable String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException; 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 2db0c660ae..071c254979 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 @@ -29,7 +29,6 @@ import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jackhuang.hmcl.util.tree.ZipFileTree; -import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; import java.nio.file.*; @@ -68,8 +67,6 @@ private interface ModMetadataReader { private final HashMap, LocalMod> localMods = new HashMap<>(); private GameComponentAnalyzer analyzer; - private boolean loaded = false; - /// Creates a mod manager for the given instance. /// /// @param instance the snapshot member whose mods directory this manager operates on @@ -105,6 +102,7 @@ public boolean hasMod(String modId, ModLoaderType modLoaderType) { } } + /// Call this only in [#refresh()] private void addModInfo(Path file) { String fileName = StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION); String extension = fileName.substring(fileName.lastIndexOf(".") + 1); @@ -192,15 +190,15 @@ public void refresh() throws IOException { 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); + for (Path item : modsDirectoryStream) { + if (supportSubfolders && Files.isDirectory(item) && !".connector".equalsIgnoreCase(item.getFileName().toString())) { + try (DirectoryStream subDirectoryStream = Files.newDirectoryStream(item)) { + for (Path subItem : subDirectoryStream) { + addModInfo(subItem); } } } else { - addModInfo(subitem); + addModInfo(item); } } } @@ -216,17 +214,6 @@ public Comparator getComparator() { return LocalModFile::compareTo; } - public @Unmodifiable List getLocalFiles() throws IOException { - lock.lock(); - try { - if (!loaded) - refresh(); - return super.getLocalFiles(); - } finally { - lock.unlock(); - } - } - public void addMod(Path file) throws IOException { if (!isFileNameMod(file)) throw new IllegalArgumentException("File " + file + " is not a valid mod file."); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java index 632c79d3a9..5fc3336043 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/CurseForgeRemoteAddonRepository.java @@ -208,7 +208,7 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion } /// Calculates the CurseForge fingerprint without retaining the filtered file in memory. - static long calculateFingerprint(Path file) throws IOException { + public static long calculateFingerprint(Path file) throws IOException { try (SeekableByteChannel channel = Files.newByteChannel(file, StandardOpenOption.READ)) { long startPosition = channel.position(); @@ -310,19 +310,30 @@ public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOE } } - @Override - public Stream getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException { + private List getLatestFiles(DownloadProvider downloadProvider, String addonId) throws IOException { SEMAPHORE.acquireUninterruptibly(); try { - Response> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id + "/files", + Response> response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + addonId + "/files", pair("pageSize", "10000"))) .getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class))); - return response.data().stream().map(CurseAddon.LatestFile::toVersion); + return response.data(); } finally { SEMAPHORE.release(); } } + @Override + public Stream getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException { + return getLatestFiles(downloadProvider, id).stream().map(CurseAddon.LatestFile::toVersion); + } + + @Override + public boolean hasRemoteVersionWithHashes(DownloadProvider downloadProvider, String id, Set hashes) throws IOException { + if (hashes.isEmpty() || hashes.stream().anyMatch(o -> !(o instanceof Long))) + return false; + return getLatestFiles(downloadProvider, id).stream().map(CurseAddon.LatestFile::fileFingerprint).anyMatch(hashes::contains); + } + @Override public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException { SEMAPHORE.acquireUninterruptibly(); @@ -509,6 +520,7 @@ else if (StringUtils.isNotBlank(logo.url())) } return new RemoteAddon( + Integer.toString(id), slug, "", name, @@ -517,7 +529,8 @@ else if (StringUtils.isNotBlank(logo.url())) links.websiteUrl, iconUrl, this, - toAddonType(classId) + toAddonType(classId), + RemoteAddon.Source.CURSEFORGE ); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java index b8de101b0e..d19a6165fc 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/repository/ModrinthRemoteAddonRepository.java @@ -280,8 +280,7 @@ public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOE throw new UnsupportedOperationException(); } - @Override - public Stream getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException { + private List getProjectVersions(DownloadProvider downloadProvider, String id) throws IOException { SEMAPHORE.acquireUninterruptibly(); try { id = StringUtils.removePrefix(id, "local-"); @@ -291,9 +290,7 @@ public Stream getRemoteVersionsById(DownloadProvider downlo for (URI candidate : candidates) { try { - List versions = HttpRequest.GET(candidate.toString()) - .getJson(listTypeOf(ProjectVersion.class)); - return versions.stream().map(ProjectVersion::toVersion).flatMap(Lang::toStream); + return HttpRequest.GET(candidate.toString()).getJson(listTypeOf(ProjectVersion.class)); } catch (IOException e) { IOException wrapper = new IOException("Failed to get remote versions: " + candidate, e); if (candidates.size() == 1) { @@ -313,6 +310,24 @@ public Stream getRemoteVersionsById(DownloadProvider downlo } } + @Override + public Stream getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException { + return getProjectVersions(downloadProvider, id).stream().map(ProjectVersion::toVersion).flatMap(Optional::stream); + } + + @Override + public boolean hasRemoteVersionWithHashes(DownloadProvider downloadProvider, String id, Set hashes) throws IOException { + if (hashes.isEmpty() || hashes.stream().anyMatch(o -> !(o instanceof String))) + return false; + return getProjectVersions(downloadProvider, id).stream() + .map(ProjectVersion::files) + .filter(files -> !files.isEmpty()) + .map(files -> files.get(0)) + .map(file -> file.hashes().get("sha1")) + .filter(Objects::nonNull) + .anyMatch(hashes::contains); + } + @Override public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException { SEMAPHORE.acquireUninterruptibly(); @@ -403,6 +418,7 @@ public Stream loadVersions(RemoteAddonRepository repo, Down public RemoteAddon toAddon() { return new RemoteAddon( + id, slug, "", title, @@ -411,7 +427,8 @@ public RemoteAddon toAddon() { String.format("https://modrinth.com/%s/%s", projectType, id), iconUrl, this, - toAddonType(projectType) + toAddonType(projectType), + RemoteAddon.Source.MODRINTH ); } } @@ -529,6 +546,7 @@ public Stream loadVersions(RemoteAddonRepository repo, Down public RemoteAddon toAddon() { return new RemoteAddon( + projectId, slug, author, title, @@ -537,7 +555,8 @@ public RemoteAddon toAddon() { String.format("https://modrinth.com/%s/%s", projectType, projectId), iconUrl, this, - toAddonType(projectType) + toAddonType(projectType), + RemoteAddon.Source.MODRINTH ); } } 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 cb7a39e99c..003d62f7ef 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 @@ -34,7 +34,6 @@ import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; import java.io.UncheckedIOException; @@ -219,8 +218,6 @@ private static List deserializePackList(String json) { private @Nullable PackMcMeta.PackVersion requiredVersion; private boolean supportsNewOptionsFormat; - private boolean loaded = false; - /// Creates a resource-pack manager for the given instance. /// /// @param instance the snapshot member whose resource packs this manager operates on @@ -343,39 +340,18 @@ public Comparator getComparator() { return ResourcePackFile::compareTo; } - @Override - public @Unmodifiable List getLocalFiles() throws IOException { - lock.lock(); - try { - if (!loaded) - refresh(); - return super.getLocalFiles(); - } finally { - lock.unlock(); - } - } - - public void importResourcePack(Path file) throws IOException, IllegalArgumentException { - lock.lock(); - try { - if (ResourcePackFile.isFileResourcePack(file)) { - if (!loaded) - refresh(); - Files.createDirectories(resourcePackDirectory); - - Path newFile = resourcePackDirectory.resolve(file.getFileName()); - if (Files.isDirectory(file)) { - FileUtils.copyDirectory(file, newFile); - } else { - FileUtils.copyFile(file, newFile); - } + public void addResourcePack(Path file) throws IOException, IllegalArgumentException { + if (ResourcePackFile.isFileResourcePack(file)) { + Files.createDirectories(resourcePackDirectory); - addResourcePackInfo(newFile); + Path newFile = resourcePackDirectory.resolve(file.getFileName()); + if (Files.isDirectory(file)) { + FileUtils.copyDirectory(file, newFile); } else { - throw new IllegalArgumentException("File '" + file + "' is not a resource pack"); + FileUtils.copyFile(file, newFile); } - } finally { - lock.unlock(); + } else { + throw new IllegalArgumentException("File '" + file + "' is not a resource pack"); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java index 2ca68211a5..079328d948 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/auth/offline/YggdrasilServer.java @@ -22,7 +22,6 @@ import org.jackhuang.hmcl.auth.yggdrasil.GameProfile; import org.jackhuang.hmcl.auth.yggdrasil.TextureModel; import org.jackhuang.hmcl.util.KeyUtils; -import org.jackhuang.hmcl.util.Lang; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.HttpServer; @@ -85,7 +84,7 @@ private Response profiles(Request request) throws IOException { List names = JsonUtils.fromNonNullJsonFully(request.getSession().getInputStream(), listTypeOf(String.class)); return ok(names.stream().distinct() .map(this::findCharacterByName) - .flatMap(Lang::toStream) + .flatMap(Optional::stream) .map(Character::toSimpleResponse) .collect(Collectors.toList())); } 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 87a3a86742..ecf36a08ca 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameInstance.java @@ -18,6 +18,9 @@ package org.jackhuang.hmcl.game; import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.addon.LocalAddonFile; +import org.jackhuang.hmcl.addon.LocalAddonManager; +import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.addon.resourcepack.ResourcePackManager; import org.jackhuang.hmcl.util.gson.JsonUtils; @@ -186,6 +189,21 @@ public GameVersionNumber getVersion() { return version; } + /// Returns the manager for this snapshot member and the specific addon type. + /// + /// 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 manager for the specific addon type, or null if not supported + public @Nullable LocalAddonManager getManagerForType(RemoteAddon.Type type) { + return switch (type) { + case MOD -> getModManager(); + case RESOURCE_PACK -> getResourcePackManager(); + default -> null; + }; + } + /// Returns the mod manager for this snapshot member. /// /// The manager is created on first use and is not shared with other snapshot wrappers. After a diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java index c00a776785..4f221be102 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/Lang.java @@ -349,11 +349,6 @@ public static BiConsumer wrap(ExceptionalBiConsumer fn) { }; } - @SuppressWarnings("OptionalUsedAsFieldOrParameterType") - public static Stream toStream(Optional optional) { - return optional.map(Stream::of).orElseGet(Stream::empty); - } - public static Iterable toIterable(Enumeration enumeration) { if (enumeration == null) { throw new NullPointerException(); From 93f5ed73fb83b8f151a33165004678ac6190c771 Mon Sep 17 00:00:00 2001 From: ToobLac Date: Sat, 22 Aug 2026 17:20:52 +0800 Subject: [PATCH 03/17] update --- .../hmcl/ui/construct/TwoLineListItem.java | 12 ++++- .../hmcl/ui/instances/DownloadPage.java | 47 ++++++++++--------- .../resources/assets/lang/I18N.properties | 2 +- .../resources/assets/lang/I18N_zh.properties | 2 +- .../assets/lang/I18N_zh_CN.properties | 2 +- .../org/jackhuang/hmcl/addon/RemoteAddon.java | 43 +++++++++-------- .../CurseForgeRemoteAddonRepository.java | 23 +-------- .../ModrinthRemoteAddonRepository.java | 40 +--------------- .../modpack/curse/CurseCompletionTask.java | 9 ++-- 9 files changed, 70 insertions(+), 110 deletions(-) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TwoLineListItem.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TwoLineListItem.java index f9f90e9590..da20d5c951 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TwoLineListItem.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/TwoLineListItem.java @@ -29,6 +29,7 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.ui.FXUtils; +import org.jetbrains.annotations.Nullable; public class TwoLineListItem extends VBox { private static final String DEFAULT_STYLE_CLASS = "two-line-list-item"; @@ -182,7 +183,7 @@ public ObservableList