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 2f5159ae25..d6eb388718 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteAddonRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteAddonRepository.java @@ -37,22 +37,22 @@ public abstract class LocalizedRemoteAddonRepository implements RemoteAddonRepos private static final int INITIAL_CAPACITY = 16; - protected abstract RemoteAddonRepository getBackedRemoteModRepository(); + protected abstract RemoteAddonRepository getBackedRepository(); @Override public String getApiBaseUrl() { - return getBackedRemoteModRepository().getApiBaseUrl(); + return getBackedRepository().getApiBaseUrl(); } @Override public String getBaseUrl() { - return getBackedRemoteModRepository().getBaseUrl(); + return getBackedRepository().getBaseUrl(); } @Override public SearchResult search(DownloadProvider downloadProvider, String gameVersion, Category category, int pageOffset, int pageSize, String searchFilter, SortType sort, SortOrder sortOrder) throws IOException { if (!StringUtils.containsChinese(searchFilter)) { - return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder); + return getBackedRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder); } Set englishSearchFiltersSet = new LinkedHashSet<>(INITIAL_CAPACITY); @@ -69,7 +69,7 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion } if (englishSearchFiltersSet.isEmpty()) { - return getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder); + return getBackedRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, searchFilter, sort, sortOrder); } RemoteAddon[] searchResultArray = new RemoteAddon[pageSize]; @@ -78,7 +78,7 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion SearchResult searchResult = null; List remoteAddons = List.of(); for (String englishSearchFilter : englishSearchFiltersSet) { - searchResult = getBackedRemoteModRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder); + searchResult = getBackedRepository().search(downloadProvider, gameVersion, category, pageOffset, pageSize, englishSearchFilter, SortType.RELEVANCY, sortOrder); remoteAddons = searchResult.results().toList(); if (!remoteAddons.isEmpty()) { break; @@ -123,41 +123,41 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion @Override public Stream getCategories() throws IOException { - return getBackedRemoteModRepository().getCategories(); + return getBackedRepository().getCategories(); } @Override public Optional getRemoteVersionByLocalFile(Path file) throws IOException { - return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file); + return getBackedRepository().getRemoteVersionByLocalFile(file); } @Override public RemoteAddon getAddonById(DownloadProvider downloadProvider, String id) throws IOException { - return getBackedRemoteModRepository().getAddonById(downloadProvider, id); + return getBackedRepository().getAddonById(downloadProvider, id); } @Override public RemoteAddon resolveDependency(DownloadProvider downloadProvider, String id) throws IOException { - return getBackedRemoteModRepository().resolveDependency(downloadProvider, id); + return getBackedRepository().resolveDependency(downloadProvider, id); } @Override public RemoteAddon.File getAddonFile(String projectId, String fileId) throws IOException { - return getBackedRemoteModRepository().getAddonFile(projectId, fileId); + return getBackedRepository().getAddonFile(projectId, fileId); } @Override public Stream getRemoteVersionsById(DownloadProvider downloadProvider, String id) throws IOException { - return getBackedRemoteModRepository().getRemoteVersionsById(downloadProvider, id); + return getBackedRepository().getRemoteVersionsById(downloadProvider, id); } @Override public String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException { - return getBackedRemoteModRepository().getAddonChangelog(downloadProvider, addonId, versionId); + return getBackedRepository().getAddonChangelog(downloadProvider, addonId, versionId); } @Override public @NotNull String getVersionPageUrl(RemoteAddon.Version version) throws IOException { - return getBackedRemoteModRepository().getVersionPageUrl(version); + return getBackedRepository().getVersionPageUrl(version); } } 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 56fdf61cd0..c641295ec2 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 @@ -181,7 +181,8 @@ public Label getSubtitleLabel() { return lblSubtitle; } - private static Label createTag(String tag, PseudoClass pseudoClass) { + /// Creates a tag label and activates the pseudo-class when it is non-null. + private static Label createTag(@Nullable String tag, @Nullable PseudoClass pseudoClass) { var tagLabel = new Label(tag); tagLabel.getStyleClass().add("tag"); tagLabel.setMinWidth(Label.USE_PREF_SIZE); @@ -239,7 +240,8 @@ private double computeNaturalPrefWidth(double height) { } } - public void addTag(String tag, PseudoClass pseudoClass) { + /// Appends a tag and activates the pseudo-class when it is non-null. + public void addTag(@Nullable String tag, @Nullable PseudoClass pseudoClass) { getTags().add(createTag(tag, pseudoClass)); } @@ -247,10 +249,17 @@ public void addTag(String tag) { addTag(tag, null); } + /// Prepends a tag and activates the pseudo-class when it is non-null. + public void addTagFirst(@Nullable String tag, @Nullable PseudoClass pseudoClass) { + getTags().add(0, createTag(tag, pseudoClass)); + } + + /// Appends tags in iteration order, retaining duplicate text. public void addTags(Collection tags) { getTags().addAll(tags.stream().map(tag -> createTag(tag, null)).toList()); } + /// Appends each distinct tag text absent from the current labels, in iteration order. public void addTagsIfNotExist(Collection tags) { var current = getTags().stream().map(Label::getText).collect(Collectors.toSet()); var target = new LinkedHashSet<>(tags); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index e96cc90cc2..1fda5c0761 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -70,11 +70,11 @@ public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage { public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_MOD = - (downloadProvider, repository, version, mod, file) -> download(downloadProvider, repository, version, file, "mods"); + (downloadProvider, repository, version, mod, file) -> download(downloadProvider, repository, version, file, "mods", RemoteAddon.Type.MOD); public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_RESOURCE_PACK = - (downloadProvider, repository, version, pack, file) -> download(downloadProvider, repository, version, file, "resourcepacks"); + (downloadProvider, repository, version, pack, file) -> download(downloadProvider, repository, version, file, "resourcepacks", RemoteAddon.Type.RESOURCE_PACK); public static final org.jackhuang.hmcl.ui.instances.DownloadPage.DownloadCallback FOR_SHADER = - (downloadProvider, repository, version, shader, file) -> download(downloadProvider, repository, version, file, "shaderpacks"); + (downloadProvider, repository, version, shader, file) -> download(downloadProvider, repository, version, file, "shaderpacks", RemoteAddon.Type.SHADER_PACK); private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("download"), -1)); private final TabHeader tab; @@ -142,7 +142,7 @@ private static Supplier loadVersionFor(Supplier nodeSuppl }; } - public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName) { + public static void download(DownloadProvider downloadProvider, HMCLGameRepository repository, @Nullable GameInstanceID instanceId, RemoteAddon.Version file, String subdirectoryName, RemoteAddon.Type type) { @Nullable HMCLGameInstance instance = instanceId != null ? repository.findInstance(instanceId) : repository.getSelectedInstance(); @@ -161,10 +161,13 @@ public static void download(DownloadProvider downloadProvider, HMCLGameRepositor Controllers.prompt(i18n("archive.file.name"), (result, handler) -> { Path dest = targetPath.resolve(result); - Controllers.taskDialog(Task.composeAsync(() -> { - var task = new FileDownloadTask(downloadProvider.injectURLWithCandidates(file.file().url()), dest); - task.setName(file.name()); - return task; + Controllers.taskDialog(Task.composeAsync(() -> + new FileDownloadTask(downloadProvider.injectURLWithCandidates(file.file().url()), dest).setName(file.name()) + ).whenComplete(Schedulers.io(), exception -> { + if (exception == null && instance != null) { + var manager = instance.getManagerForType(type); + if (manager != null) manager.invalidate(); + } }).whenComplete(Schedulers.javafx(), exception -> { if (exception != null) { if (!(exception instanceof CancellationException)) { 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 386988de72..e6c9a874a4 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 @@ -19,6 +19,8 @@ import com.jfoenix.controls.JFXButton; import com.jfoenix.controls.JFXDialogLayout; +import javafx.beans.InvalidationListener; +import javafx.beans.Observable; import javafx.beans.binding.Bindings; import javafx.beans.property.BooleanProperty; import javafx.beans.property.ReadOnlyObjectProperty; @@ -74,6 +76,7 @@ public class DownloadPage extends Control implements DecoratorPage { private final RemoteAddon.Type type; private SimpleMultimap> versions; + private Set installed; public DownloadPage(DownloadListPage page, RemoteAddon addon, HMCLGameInstance.Optional instanceReference, @Nullable DownloadCallback callback) { this.page = page; @@ -94,11 +97,12 @@ private void loadAddonVersions() { setFailed(false); Task.supplyAsync(() -> { - Stream versions = addon.data().loadVersions(repository, page.getDownloadProvider()); - return sortVersions(versions); + List versions = repository.getRemoteVersionsById(getDownloadProvider(), addon.id()).toList(); + return Pair.pair(sortVersions(versions.stream()), addon.checkInstalled(versions.stream(), instanceReference.instance())); }).whenComplete(Schedulers.javafx(), (result, exception) -> { if (exception == null) { - this.versions = result; + this.versions = result.key(); + this.installed = result.value(); loaded.set(true); setFailed(false); @@ -129,6 +133,10 @@ public RemoteAddon getAddon() { return addon; } + public DownloadProvider getDownloadProvider() { + return page.getDownloadProvider(); + } + public HMCLGameInstance.Optional getInstanceOptional() { return instanceReference; } @@ -231,6 +239,21 @@ protected DownloadPageSkin(DownloadPage control) { content.getFirstLine().setMinWidth(0); descriptionPane.getChildren().add(content); + { + var installedListener = new InvalidationListener() { + @Override + public void invalidated(Observable observable) { + if (control.loaded.get() && control.installed != null) { + if (!control.installed.isEmpty()) + content.addTagFirst(i18n("addon.installed"), null); + observable.removeListener(this); + } + } + }; + control.loaded.addListener(installedListener); + installedListener.invalidated(control.loaded); + } + if (getSkinnable().mod != null) { JFXHyperlink openMcmodButton = new JFXHyperlink(i18n("mods.mcmod")); openMcmodButton.setExternalLink(getSkinnable().translations.getMcmodUrl(getSkinnable().mod)); @@ -373,7 +396,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); @@ -402,6 +425,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.installed")); for (String category : addon.categories()) { if (page.shouldDisplayCategory(category)) content.addTag(page.getLocalizedCategory(category, null)); @@ -466,6 +491,20 @@ private static final class AddonItem extends StackPane { tags.add(I18n.translateLoaderName(loader)); } content.addTags(tags); + { + var installedListener = new InvalidationListener() { + @Override + public void invalidated(Observable observable) { + if (selfPage.loaded.get() && selfPage.installed != null) { + if (selfPage.installed.contains(dataItem)) + content.addTag(i18n("addon.installed"), null); + observable.removeListener(this); + } + } + }; + selfPage.loaded.addListener(installedListener); + installedListener.invalidated(selfPage.loaded); + } descPane.getChildren().setAll(graphicPane, content); } @@ -483,7 +522,7 @@ private static final class AddonItem extends StackPane { } private static final class AddonVersion extends JFXDialogLayout { - public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage selfPage) { + public AddonVersion(RemoteAddon addon, RemoteAddon.Version version, DownloadPage selfPage) { RemoteAddon.Type type = selfPage.type; String title = switch (type) { @@ -497,12 +536,12 @@ public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage s VBox box = new VBox(8); box.setPadding(new Insets(8)); - var addonItem = new AddonItem(mod, version, selfPage); + var addonItem = new AddonItem(addon, version, selfPage); addonItem.setMouseTransparent(true); // Item is displayed for info, clicking shouldn't open the dialog again 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); @@ -514,8 +553,8 @@ public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage s SpinnerPane spinnerPane = new SpinnerPane(); ScrollPane scrollPane = new ScrollPane(); ComponentList dependenciesList = new ComponentList(); - loadDependencies(version, selfPage, spinnerPane, dependenciesList); - spinnerPane.setOnFailedAction(e -> loadDependencies(version, selfPage, spinnerPane, dependenciesList)); + loadDependencies(type, version, selfPage, spinnerPane, dependenciesList); + spinnerPane.setOnFailedAction(e -> loadDependencies(type, version, selfPage, spinnerPane, dependenciesList)); scrollPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); scrollPane.setContent(dependenciesList); @@ -538,7 +577,7 @@ public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage s if (type == RemoteAddon.Type.MODPACK || !spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) { fireEvent(new DialogCloseEvent()); } - selfPage.download(mod, version); + selfPage.download(addon, version); }); } @@ -567,7 +606,7 @@ public AddonVersion(RemoteAddon mod, RemoteAddon.Version version, DownloadPage s onEscPressed(this, cancelButton::fire); } - private void loadDependencies(RemoteAddon.Version version, DownloadPage selfPage, SpinnerPane spinnerPane, ComponentList dependenciesList) { + private void loadDependencies(RemoteAddon.Type thisAddonType, RemoteAddon.Version version, DownloadPage selfPage, SpinnerPane spinnerPane, ComponentList dependenciesList) { spinnerPane.setLoading(true); Task.composeAsync(() -> { // TODO: Massive tasks may cause OOM. @@ -588,14 +627,21 @@ 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 depAddon = dependency.load(selfPage.getDownloadProvider()); + if (depAddon == RemoteAddon.BROKEN || thisAddonType == RemoteAddon.Type.MODPACK) return Pair.pair(depAddon, List.of()); + return Pair.pair(depAddon, depAddon.checkInstalled( + selfPage.repository.getRemoteVersionsById(selfPage.getDownloadProvider(), depAddon.id()), + selfPage.getInstanceOptional().instance() + )); + }) .setSignificance(Task.TaskSignificance.MINOR) .thenAcceptAsync(Schedulers.javafx(), dep -> { - if (dep == RemoteAddon.BROKEN) { + if (dep.key() == RemoteAddon.BROKEN) { hasBroken.set(true); return; } - DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep, selfPage.instanceReference); + DependencyAddonItem dependencyAddonItem = new DependencyAddonItem(selfPage.page, dep.key(), selfPage.instanceReference, !dep.value().isEmpty()); var listener = FXUtils.onWeakChangeAndOperate(dependenciesList.widthProperty(), d -> FXUtils.setLimitWidth(dependencyAddonItem, d.doubleValue())); dependencyAddonItem.getProperties().put("DependencyAddonItem.width", listener); dependencies.get(dependency.getType()).value().add(dependencyAddonItem); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java index afa638b972..45e3f8812c 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/HMCLLocalizedDownloadListPage.java @@ -98,7 +98,7 @@ public Repository(RemoteAddon.Type type, CurseForgeRemoteAddonRepository curseFo } @Override - protected RemoteAddonRepository getBackedRemoteModRepository() { + protected RemoteAddonRepository getBackedRepository() { if ("addon.modrinth".equals(downloadSource.get())) { return modrinth; } else { @@ -110,6 +110,7 @@ protected RemoteAddonRepository getBackedRemoteModRepository() { public RemoteAddon.Type getType() { return type; } + } @Override 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 5c7caedb18..67b78d98a6 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 @@ -111,7 +111,7 @@ public ModListPage(ObservableValue instance LOG.warning("Unable to parse mod file " + it, e); } }); - loadMods(modManager); + refresh(); }); listenerHolder.add(FXUtils.onWeakChangeAndOperate(instanceContext, current -> { @@ -205,7 +205,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(); } @@ -215,7 +215,7 @@ void removeSelected(ObservableList selectedItems) { .filter(Objects::nonNull) .map(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/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/instances/ResourcePackListPage.java index 8440abc7e0..cc762e4809 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 9a56febc90..e50f064919 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -189,6 +189,7 @@ addon.dependencies.has_broken=This version has broken dependencies. Please try a addon.download.recommend=Recommended Version for Minecraft %1s addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (Snapshots) +addon.installed=Installed addon.modrinth=Modrinth addon.sort.date_created=Date Created addon.sort.last_updated=Last Updated diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 635db716b6..8bc856e2c2 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -190,6 +190,7 @@ addon.curseforge=CurseForge # addon.download.recommend= # addon.download.title.release= # addon.download.title.snapshot= +# addon.installed= addon.modrinth=Modrinth addon.sort.date_created=تاريخ الإنشاء addon.sort.last_updated=آخر تحديث diff --git a/HMCL/src/main/resources/assets/lang/I18N_de.properties b/HMCL/src/main/resources/assets/lang/I18N_de.properties index 0e3534cb79..4b2b8a3034 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_de.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_de.properties @@ -188,6 +188,7 @@ addon.dependency.broken=Fehlerhafte Abhängigkeiten (Dieses Addon existierte vor addon.download.recommend=Empfohlene Version für Minecraft %1s addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (Snapshots) +# addon.installed= addon.modrinth=Modrinth addon.sort.date_created=Erstellungsdatum addon.sort.last_updated=Zuletzt aktualisiert diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index 38d32da099..ad1599bb92 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -188,6 +188,7 @@ addon.curseforge=CurseForge addon.download.recommend=Versión recomendada - Minecraft %1s # addon.download.title.release= # addon.download.title.snapshot= +# addon.installed= addon.modrinth=Modrinth addon.sort.date_created=Fecha de creación addon.sort.last_updated=Última actualización diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index d6f3e2fc5d..959939a2e3 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -177,6 +177,7 @@ addon.curseforge=CurseForge # addon.download.recommend= # addon.download.title.release= # addon.download.title.snapshot= +# addon.installed= addon.modrinth=Modrinth addon.sort.date_created=作成日 addon.sort.last_updated=最終更新 diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 0abaf77bbf..e55c550ee1 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -188,6 +188,7 @@ addon.curseforge=CurseForge addon.download.recommend=宜版 - 礦藝 %1s # addon.download.title.release= # addon.download.title.snapshot= +# addon.installed= addon.modrinth=Modrinth addon.sort.date_created=創立之期 addon.sort.last_updated=近日迭更 diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 8b75ae658b..b1a96e7b34 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -184,6 +184,7 @@ addon.dependencies.has_broken=У этой версии есть сломанны addon.download.recommend=Рекомендуемая версия для Minecraft %1s addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (снапшоты) +# addon.installed= addon.modrinth=Modrinth addon.sort.date_created=Создан addon.sort.last_updated=Обновлён diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 6f1985bff0..81b94c6660 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -190,6 +190,7 @@ addon.dependency.broken=Зламані залежності (Це доповне addon.download.recommend=Рекомендована версія для Minecraft %1s addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (Снапшоти) +# addon.installed= addon.modrinth=Modrinth addon.sort.date_created=Дата створення addon.sort.last_updated=Останнє оновлення diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties index be22b30fc3..faee6f590c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hans.properties @@ -189,6 +189,7 @@ addon.dependencies.has_broken=此版本存在损坏的前置内容。请尝试 addon.download.recommend=推荐版本 - Minecraft %1s addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (快照) +addon.installed=已安装 addon.modrinth=Modrinth addon.sort.date_created=创建日期 addon.sort.last_updated=最近更新 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties index 458f32c556..f0b2b3cf1a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_Hant.properties @@ -187,6 +187,7 @@ addon.dependencies.has_broken=此版本存在損壞的前置內容。請嘗試 addon.download.recommend=推薦版本 - Minecraft %1s addon.download.title.release=Minecraft %s addon.download.title.snapshot=Minecraft %s (快照) +addon.installed=已安裝 addon.modrinth=Modrinth addon.sort.date_created=建立日期 addon.sort.last_updated=最近更新 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..669bc6ad2e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonFile.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.addon; 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 +28,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 +44,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 +55,22 @@ public boolean isDisabled() { public abstract void delete() throws IOException; + private volatile transient String sha1 = null; + + /// Calculates SHA-1 hash. + /// + /// @return SHA-1 hash of this file, or null on failure + public @Nullable String calculateSha1() { + if (sha1 == null) { + try { + sha1 = DigestUtils.digestToString("SHA-1", getFile()); + } catch (IOException e) { + return null; + } + } + 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..ade95c5da1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/LocalAddonManager.java @@ -18,6 +18,8 @@ package org.jackhuang.hmcl.addon; import org.jackhuang.hmcl.game.DefaultGameInstance; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.FileUtils; import org.jetbrains.annotations.NotNull; @@ -27,11 +29,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 +58,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 volatile boolean loaded = false; + /// Creates a manager bound to the given instance. /// /// @param instance the snapshot member whose addon directory this manager operates on @@ -83,8 +85,19 @@ public DefaultGameInstance getInstance() { /// @return the addon directory path public abstract Path getDirectory(); + /// Marks the local file list as invalid. + public void invalidate() { + lock.lock(); + try { + loaded = false; + } finally { + lock.unlock(); + } + } + /// 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,32 +113,53 @@ 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 SHA-1 hashes for finding versions in the given remote addon source + public Set getSha1Hashes() throws Exception { + lock.lock(); + try { + if (!loaded) + refresh(); + var task = Task.allOf( + localFiles.stream().filter(file -> !file.isDisabled()) + .map(file -> Task.supplyAsync(file::calculateSha1).setExecutor(Schedulers.io()).setSignificance(Task.TaskSignificance.MINOR)) + .toList() + ).setSignificance(Task.TaskSignificance.MINOR); + task.test(); + if (task.getException() != null) + throw task.getException(); + return task.getResult().stream().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()); } else { - newPath = restoreFile(modFile.getFile()); - localFiles.add(modFile); + newPath = restoreFile(addonFile.getFile()); } + loaded = false; return newPath; } finally { lock.unlock(); 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 1c69d6a9e8..debfba7da4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/addon/RemoteAddon.java @@ -20,30 +20,45 @@ import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; import org.jackhuang.hmcl.download.DownloadProvider; +import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jackhuang.hmcl.util.StringUtils; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; import java.io.IOException; import java.time.Instant; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; 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 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(); - } - - @Override - public Stream loadVersions(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException { - throw new IOException(); +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public record RemoteAddon(String id, String slug, String author, String title, String description, List categories, + String pageUrl, String iconUrl, @Nullable Type type, @Nullable Source source) { + + public static final RemoteAddon BROKEN = new RemoteAddon("", "", "", "RemoteAddon.BROKEN", "", Collections.emptyList(), "", "", null, null); + + public @Unmodifiable Set checkInstalled(Stream remoteVersions, @Nullable DefaultGameInstance gameInstance) { + if (gameInstance != null && type() != null) { + LocalAddonManager manager = gameInstance.getManagerForType(type()); + if (manager != null) { + try { + Set localHashes = manager.getSha1Hashes(); + return remoteVersions.filter(v -> { + String sha1 = v.file.hashes.get("sha1"); + return StringUtils.isNotBlank(sha1) && localHashes.contains(sha1); + }).collect(Collectors.toUnmodifiableSet()); + } catch (Exception e) { + LOG.warning("Failed to check if addon %s on %s is installed".formatted(id(), source()), e); + } + } } - }, Type.MOD); + return Set.of(); + } public enum VersionType { Release, @@ -146,6 +161,7 @@ public enum Source { CurseForgeRemoteAddonRepository.RESOURCE_PACKS, CurseForgeRemoteAddonRepository.SHADERS, CurseForgeRemoteAddonRepository.WORLDS, + null, CurseForgeRemoteAddonRepository.MODPACKS, CurseForgeRemoteAddonRepository.CUSTOMIZATIONS ), @@ -155,6 +171,7 @@ public enum Source { ModrinthRemoteAddonRepository.RESOURCE_PACKS, ModrinthRemoteAddonRepository.SHADER_PACKS, null, + null, ModrinthRemoteAddonRepository.MODPACKS, null ); @@ -164,6 +181,7 @@ public enum Source { private final RemoteAddonRepository resourcePackRepo; private final RemoteAddonRepository shaderPackRepo; private final RemoteAddonRepository worldRepo; + private final RemoteAddonRepository dataPackRepo; private final RemoteAddonRepository modpackRepo; private final RemoteAddonRepository customizationRepo; @@ -174,6 +192,7 @@ public RemoteAddonRepository getRepoForType(Type type) { case RESOURCE_PACK -> resourcePackRepo; case SHADER_PACK -> shaderPackRepo; case WORLD -> worldRepo; + case DATA_PACK -> dataPackRepo; case MODPACK -> modpackRepo; case CUSTOMIZATION -> customizationRepo; }; @@ -189,6 +208,7 @@ public RemoteAddonRepository getCommonRepo() { RemoteAddonRepository resourcePackRepo, RemoteAddonRepository shaderPackRepo, RemoteAddonRepository worldRepo, + RemoteAddonRepository dataPackRepo, RemoteAddonRepository modpackRepo, RemoteAddonRepository customizationRepo ) { @@ -197,6 +217,7 @@ public RemoteAddonRepository getCommonRepo() { this.resourcePackRepo = resourcePackRepo; this.shaderPackRepo = shaderPackRepo; this.worldRepo = worldRepo; + this.dataPackRepo = dataPackRepo; this.modpackRepo = modpackRepo; this.customizationRepo = customizationRepo; } @@ -208,22 +229,32 @@ public enum Type { RESOURCE_PACK, SHADER_PACK, WORLD, + DATA_PACK, CUSTOMIZATION } - public interface IAddon { - List loadDependencies(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException; - - Stream loadVersions(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException; - } - public interface IVersion { Source getSource(); } + /// Describes an addon release, its download file, dependencies, and loader labels. + /// + /// @param self the repository-specific version metadata + /// @param versionId the version identifier assigned by the repository + /// @param projectId the project identifier assigned by the repository + /// @param name the display name of the release + /// @param version the repository's version string or file name + /// @param datePublished the publication time + /// @param versionType the release channel + /// @param file the file selected for downloading + /// @param dependencies the dependencies associated with the release + /// @param gameVersions the supported game version strings + /// @param loaders the loader labels, including names without a recognized loader type + /// @param hash the CurseForge unsigned fingerprint stored as a [Long], or the Modrinth + /// SHA-1 string; may be null if the Modrinth file metadata omits SHA-1 public record Version(IVersion self, String versionId, String projectId, String name, String version, Instant datePublished, VersionType versionType, File file, List dependencies, - List gameVersions, List loaders) { + List gameVersions, List loaders, @Nullable Object hash) { } public record File(Map hashes, String url, String filename) { 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 f36d7edf84..eb67cf486b 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 @@ -30,7 +30,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.*; @@ -71,8 +70,6 @@ private interface ModMetadataReader { private final EnumSet supportedLoaders = EnumSet.noneOf(ModLoaderType.class); 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 @@ -131,6 +128,7 @@ public EnumSet getSupportedLoaders() { } } + /// 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); @@ -143,14 +141,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()); } } @@ -158,7 +156,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; @@ -168,7 +166,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; @@ -230,15 +228,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); } } } @@ -257,42 +255,23 @@ 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."); - 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); + loaded = false; } public void removeMods(LocalModFile... localModFiles) throws IOException { for (LocalModFile localModFile : localModFiles) { localModFile.delete(); } + if (localModFiles.length > 0) loaded = false; } private void updateSupportedLoaders() { @@ -373,6 +352,8 @@ public void rollback(LocalModFile from, LocalModFile to) throws IOException { from.setOld(true); to.setOld(false); to.setActive(active); + + loaded = false; } finally { lock.unlock(); } @@ -387,14 +368,18 @@ public Path disableMod(Path file) throws IOException { Path disabled = file.resolveSibling(fileName + DISABLED_EXTENSION); if (Files.exists(file)) Files.move(file, disabled, StandardCopyOption.REPLACE_EXISTING); + + loaded = false; return disabled; } public Path enableMod(Path file) throws IOException { if (isOld(file)) return file; Path enabled = file.resolveSibling(StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION)); + if (enabled.equals(file)) return file; if (Files.exists(file)) Files.move(file, enabled, StandardCopyOption.REPLACE_EXISTING); + loaded = false; return enabled; } 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 e044ff332b..499f61bbbd 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 @@ -303,20 +303,25 @@ 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", + List data = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + addonId + "/files", pair("pageSize", "10000"))) .retry(DEFAULT_RETRY_COUNT) - .getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class))); - return response.data().stream().map(CurseAddon.LatestFile::toVersion); + .getJson(Response.typeOf(listTypeOf(CurseAddon.LatestFile.class))) + .data(); + return 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 String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException { SEMAPHORE.acquireUninterruptibly(); @@ -421,6 +426,7 @@ private static RemoteAddon.Type toAddonType(int classId) { case SECTION_MODPACK -> RemoteAddon.Type.MODPACK; case SECTION_RESOURCE_PACK -> RemoteAddon.Type.RESOURCE_PACK; case SECTION_WORLD -> RemoteAddon.Type.WORLD; + case SECTION_DATAPACK -> RemoteAddon.Type.DATA_PACK; case SECTION_CUSTOMIZATION -> RemoteAddon.Type.CUSTOMIZATION; case SECTION_SHADER -> RemoteAddon.Type.SHADER_PACK; case SECTION_MOD -> RemoteAddon.Type.MOD; @@ -467,7 +473,7 @@ public record CurseAddon(int id, int gameId, String name, String slug, Links lin List latestFiles, List latestFileIndices, Instant dateCreated, Instant dateModified, Instant dateReleased, boolean allowModDistribution, int gamePopularityRank, - boolean isAvailable, int thumbsUpCount) implements RemoteAddon.IAddon { + boolean isAvailable, int thumbsUpCount) { public static final Map RELATION_TYPE = mapOf( pair(1, RemoteAddon.DependencyType.EMBEDDED), pair(2, RemoteAddon.DependencyType.OPTIONAL), @@ -477,25 +483,6 @@ public record CurseAddon(int id, int gameId, String name, String slug, Links lin pair(6, RemoteAddon.DependencyType.INCLUDE) ); - @Override - public List loadDependencies(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException { - Set dependencies = latestFiles.stream() - .flatMap(latestFile -> latestFile.dependencies().stream()) - .filter(dep -> dep.relationType() == 3) - .map(Dependency::modId) - .collect(Collectors.toSet()); - List mods = new ArrayList<>(); - for (int dependencyId : dependencies) { - mods.add(repo.getAddonById(downloadProvider, Integer.toString(dependencyId))); - } - return mods; - } - - @Override - public Stream loadVersions(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException { - return repo.getRemoteVersionsById(downloadProvider, Integer.toString(id)); - } - public RemoteAddon toAddon() { String iconUrl = ""; if (logo != null) { @@ -506,6 +493,7 @@ else if (StringUtils.isNotBlank(logo.url())) } return new RemoteAddon( + Integer.toString(id), slug, "", name, @@ -513,8 +501,8 @@ else if (StringUtils.isNotBlank(logo.url())) categories.stream().map(category -> Integer.toString(category.getId())).collect(Collectors.toList()), links.websiteUrl, iconUrl, - this, - toAddonType(classId) + toAddonType(classId), + RemoteAddon.Source.CURSEFORGE ); } @@ -575,6 +563,7 @@ public RemoteAddon.Source getSource() { return RemoteAddon.Source.CURSEFORGE; } + /// Converts this file to an addon version, retaining its loader labels and fingerprint. public RemoteAddon.Version toVersion() { RemoteAddon.VersionType versionType = switch (releaseType()) { case 1 -> RemoteAddon.VersionType.Release; @@ -618,7 +607,8 @@ public RemoteAddon.Version toVersion() { gameVersions.stream() .filter(AddonLoader::mightBeLoader) .map(AddonLoader::of) - .toList() + .toList(), + fileFingerprint ); } } 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 efe88b5f00..5ad24a4e63 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 @@ -297,8 +297,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-"); @@ -308,9 +307,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(Optional::stream); + 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) { @@ -330,6 +327,11 @@ 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 String getAddonChangelog(DownloadProvider downloadProvider, String addonId, String versionId) throws IOException { SEMAPHORE.acquireUninterruptibly(); @@ -399,27 +401,11 @@ public RemoteAddonRepository.Category toCategory() { public record Project(String slug, String title, String description, List categories, String body, @SerializedName("project_type") String projectType, int downloads, @SerializedName("icon_url") String iconUrl, String id, String team, Instant published, - Instant updated, List versions) implements RemoteAddon.IAddon { - - @Override - public List loadDependencies(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException { - Set dependencies = repo.getRemoteVersionsById(downloadProvider, id()) - .flatMap(version -> version.dependencies().stream()) - .collect(Collectors.toSet()); - List addons = new ArrayList<>(); - for (RemoteAddon.Dependency dependency : dependencies) { - addons.add(dependency.load(downloadProvider)); - } - return addons; - } - - @Override - public Stream loadVersions(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException { - return repo.getRemoteVersionsById(downloadProvider, id()); - } + Instant updated, List versions) { public RemoteAddon toAddon() { return new RemoteAddon( + id, slug, "", title, @@ -427,8 +413,8 @@ public RemoteAddon toAddon() { categories, String.format("https://modrinth.com/%s/%s", projectType, id), iconUrl, - this, - toAddonType(projectType) + toAddonType(projectType), + RemoteAddon.Source.MODRINTH ); } } @@ -460,6 +446,7 @@ public RemoteAddon.Source getSource() { return RemoteAddon.Source.MODRINTH; } + /// Converts this release using its first file and SHA-1 hash, or returns empty if it has no files. public Optional toVersion() { RemoteAddon.VersionType type; if ("release".equals(versionType)) { @@ -497,7 +484,8 @@ public Optional toVersion() { return RemoteAddon.Dependency.ofGeneral(DEPENDENCY_TYPE.get(dependency.dependencyType), RemoteAddon.Source.MODRINTH, dependency.projectId); }).filter(Objects::nonNull).collect(Collectors.toList()), gameVersions, - loaders.stream().map(AddonLoader::of).toList() + loaders.stream().map(AddonLoader::of).toList(), + files.get(0).hashes().get("sha1") )); } } @@ -517,27 +505,11 @@ public record ProjectSearchResult(String slug, String title, String description, @SerializedName("project_id") String projectId, String author, List versions, @SerializedName("date_created") Instant dateCreated, @SerializedName("date_modified") Instant dateModified, - @SerializedName("latest_version") String latestVersion) implements RemoteAddon.IAddon { - - @Override - public List loadDependencies(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException { - Set dependencies = repo.getRemoteVersionsById(downloadProvider, projectId()) - .flatMap(version -> version.dependencies().stream()) - .collect(Collectors.toSet()); - List addons = new ArrayList<>(); - for (RemoteAddon.Dependency dependency : dependencies) { - addons.add(dependency.load(downloadProvider)); - } - return addons; - } - - @Override - public Stream loadVersions(RemoteAddonRepository repo, DownloadProvider downloadProvider) throws IOException { - return repo.getRemoteVersionsById(downloadProvider, projectId()); - } + @SerializedName("latest_version") String latestVersion) { public RemoteAddon toAddon() { return new RemoteAddon( + projectId, slug, author, title, @@ -545,8 +517,8 @@ public RemoteAddon toAddon() { sortDisplayCategories(displayCategories), 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 69d1477f45..c439964b7f 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 @@ -332,6 +329,7 @@ public void refresh() throws IOException { } } } + loaded = true; } finally { lock.unlock(); @@ -343,39 +341,20 @@ 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(); + + loaded = false; + } else { + throw new IllegalArgumentException("File '" + file + "' is not a resource pack"); } } @@ -386,10 +365,10 @@ public boolean removeResourcePacks(List resourcePacks) throws for (ResourcePackFile resourcePack : resourcePacks) { if (resourcePack != null && resourcePack.manager == this) { resourcePack.delete(); - localFiles.remove(resourcePack); modified = true; } } + if (modified) loaded = false; return modified; } finally { lock.unlock(); @@ -416,6 +395,7 @@ public boolean enableResourcePacks(List resourcePackFiles) { options.put("resourcePacks", serializePackList(resourcePacks)); options.put("incompatibleResourcePacks", serializePackList(incompatibleResourcePacks)); saveOptions(options); + loaded = false; } return modified; } finally { @@ -459,6 +439,7 @@ public boolean disableResourcePacks(List resourcePackFiles) { options.put("resourcePacks", serializePackList(resourcePacks)); options.put("incompatibleResourcePacks", serializePackList(incompatibleResourcePacks)); saveOptions(options); + loaded = false; } return modified; } finally { 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 041467d669..86bc595594 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/modpack/curse/CurseCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java index 0510cf1dc9..22fc11b124 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseCompletionTask.java @@ -219,15 +219,13 @@ public void execute() throws Exception { /// @throws IOException if CurseForge metadata cannot be read private @Nullable Path guessFilePath(CurseManifestFile file, DownloadProvider downloadProvider, Path resourcePacksRoot, Path shaderPacksRoot) throws IOException { RemoteAddon mod = CurseForgeRemoteAddonRepository.MODS.getAddonById(downloadProvider, Integer.toString(file.projectID())); - int classID = ((CurseForgeRemoteAddonRepository.CurseAddon) mod.data()).classId(); String fileName = file.fileName(); - return switch (classID) { - case 12, // Resource pack - 6945 -> { // Data pack + return switch (Objects.requireNonNullElse(mod.type(), RemoteAddon.Type.MOD)) { + case RESOURCE_PACK, DATA_PACK -> { Path path = resourcePacksRoot.resolve(fileName); yield Files.exists(path) ? null : path; } - case 6552 -> { // Shader pack + case SHADER_PACK -> { // Shader pack Path path = shaderPacksRoot.resolve(fileName); yield Files.exists(path) ? null : path; }