diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java index 1170688d65..82d5be10ff 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/HMCLModpackProvider.java @@ -33,6 +33,7 @@ import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; +import java.util.Set; public final class HMCLModpackProvider implements ModpackProvider { public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider(); @@ -48,7 +49,12 @@ public String getName() { } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + Path zipFile, + Modpack modpack, + @Nullable Set excludedFiles) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof HMCLModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); @@ -77,7 +83,12 @@ public Modpack readManifest(ZipArchiveReader file, Path path, Charset encoding) private final static class HMCLModpack extends Modpack { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) { + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + GameInstanceID instanceId, + String iconUrl, + @Nullable Set excludedFiles) { return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java index 65bef100c9..d18b19b6b1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/ModpackHelper.java @@ -56,6 +56,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import static org.jackhuang.hmcl.util.Lang.mapOf; import static org.jackhuang.hmcl.util.Pair.pair; @@ -196,6 +197,26 @@ public static Task getInstallManuallyCreatedModpackTask(Path zipFile, String } public static Task getInstallTask(HMCLGameRepository repository, Path zipFile, GameInstanceID instanceId, Modpack modpack, @Nullable String iconUrl) { + return getInstallTask(repository, zipFile, instanceId, modpack, iconUrl, null); + } + + /// Creates an install task that respects optional-file selection. + /// + /// @param repository the target repository + /// @param zipFile the modpack archive + /// @param instanceId the new instance id + /// @param modpack the parsed modpack + /// @param iconUrl the optional icon URL, or `null` + /// @param excludedFiles keys of optional files the user chose not to install; `null` means install all. + /// When non-null, must not contain `null` elements. + /// @return the install task + public static Task getInstallTask( + HMCLGameRepository repository, + Path zipFile, + GameInstanceID instanceId, + Modpack modpack, + @Nullable String iconUrl, + @Nullable Set excludedFiles) { ExceptionalRunnable success = () -> { repository.refresh(); repository.getInstance(instanceId).enableIsolation(); @@ -209,17 +230,17 @@ public static Task getInstallTask(HMCLGameRepository repository, Path zipFile }; if (modpack.getManifest() instanceof MultiMCInstanceConfiguration) - return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl) + return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles) .whenComplete(Schedulers.defaultScheduler(), success, failure) .thenComposeAsync(createMultiMCPostInstallTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId)) .withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries"))); else if (modpack.getManifest() instanceof McbbsModpackManifest) - return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl) + return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles) .whenComplete(Schedulers.defaultScheduler(), success, failure) .thenComposeAsync(createMcbbsPostInstallTask(repository, (McbbsModpackManifest) modpack.getManifest(), instanceId)) .withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries"))); else - return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl) + return modpack.getInstallTask(repository.getDependency(), zipFile, instanceId, iconUrl, excludedFiles) .whenComplete(Schedulers.defaultScheduler(), success, failure) .withStagesHints(new Task.StagesHint("hmcl.modpack"), new Task.StagesHint("hmcl.modpack.download", List.of("hmcl.install.assets", "hmcl.install.libraries"))); } @@ -240,17 +261,38 @@ public static Task getUpdateTask(HMCLGameRepository repository, ServerModp } public static Task getUpdateTask(HMCLGameRepository repository, Path zipFile, Charset charset, GameInstanceID instanceId, ModpackConfiguration configuration) throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException { + return getUpdateTask(repository, zipFile, charset, instanceId, configuration, null); + } + + /// Creates an update task that respects optional-file selection. + /// + /// @param repository the target repository + /// @param zipFile the modpack archive + /// @param charset the archive encoding + /// @param instanceId the instance to update + /// @param configuration the existing modpack configuration + /// @param excludedFiles keys of optional files the user chose not to install; `null` means install all. + /// When non-null, must not contain `null` elements. + /// @return the update task + public static Task getUpdateTask( + HMCLGameRepository repository, + Path zipFile, + Charset charset, + GameInstanceID instanceId, + ModpackConfiguration configuration, + @Nullable Set excludedFiles) + throws UnsupportedModpackException, ManuallyCreatedModpackException, MismatchedModpackTypeException { Modpack modpack = ModpackHelper.readModpackManifest(zipFile, charset); ModpackProvider provider = getProviderByType(configuration.getType()); if (provider == null) { throw new UnsupportedModpackException(); } if (modpack.getManifest() instanceof MultiMCInstanceConfiguration) - return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack) + return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles) .thenComposeAsync(() -> createMultiMCPostUpdateTask(repository, (MultiMCInstanceConfiguration) modpack.getManifest(), instanceId)) .thenComposeAsync(repository.refreshAsync()); else - return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack) + return provider.createUpdateTask(repository.getDependency(), repository.getInstance(instanceId), zipFile, modpack, excludedFiles) .thenComposeAsync(repository.refreshAsync()); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java index 91f10ea56c..ab53802818 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/construct/MDListCell.java @@ -21,12 +21,14 @@ import javafx.beans.binding.DoubleBinding; import javafx.css.PseudoClass; import javafx.scene.control.ListCell; +import javafx.scene.control.ListView; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import org.jackhuang.hmcl.ui.FXUtils; public abstract class MDListCell extends ListCell { private static final PseudoClass SELECTED = PseudoClass.getPseudoClass("selected"); + private static final PseudoClass LAST = PseudoClass.getPseudoClass("last"); private final StackPane container = new StackPane(); private final StackPane root = new StackPane(); @@ -58,7 +60,10 @@ protected void updateItem(T item, boolean empty) { super.updateItem(item, empty); - if (oldItem == item && oldEmpty == empty) return; + if (oldItem == item && oldEmpty == empty) { + updateLastPseudoClass(empty); + return; + } ripplerContainer.releaseRippleImmediately(); @@ -68,6 +73,19 @@ protected void updateItem(T item, boolean empty) { } else { setGraphic(root); } + updateLastPseudoClass(empty || item == null); + } + + /// Updates the `:last` pseudo-class so the trailing divider can be suppressed in CSS. + /// + /// @param empty whether this cell is empty + private void updateLastPseudoClass(boolean empty) { + ListView listView = getListView(); + boolean last = !empty + && listView != null + && getIndex() >= 0 + && getIndex() == listView.getItems().size() - 1; + root.pseudoClassStateChanged(LAST, last); } protected StackPane getContainer() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java index 0b1c740c95..7b575be197 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/LocalModpackPage.java @@ -20,11 +20,18 @@ import javafx.application.Platform; import javafx.beans.property.BooleanProperty; import javafx.beans.property.SimpleBooleanProperty; +import javafx.collections.FXCollections; +import javafx.collections.ObservableList; +import javafx.collections.ObservableSet; +import javafx.collections.transformation.FilteredList; import javafx.stage.FileChooser; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.game.ManuallyCreatedModpackException; import org.jackhuang.hmcl.game.ModpackHelper; import org.jackhuang.hmcl.modpack.Modpack; +import org.jackhuang.hmcl.modpack.ModpackFile; +import org.jackhuang.hmcl.modpack.ModpackManifest; +import org.jackhuang.hmcl.setting.DownloadProviders; import org.jackhuang.hmcl.setting.GameDirectoryManager; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -39,9 +46,15 @@ import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Nullable; import java.nio.charset.Charset; import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; import static org.jackhuang.hmcl.util.logging.Logger.LOG; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; @@ -51,10 +64,22 @@ public final class LocalModpackPage extends ModpackPage { private final BooleanProperty installAsVersion = new SimpleBooleanProperty(true); private Modpack manifest = null; private Charset charset; + private final ObservableList allFiles = FXCollections.observableList(new ArrayList<>()); + private final ObservableSet excludedFiles = FXCollections.observableSet(new HashSet<>()); + private final BooleanProperty loadingOptionalFiles = new SimpleBooleanProperty(false); + private final BooleanProperty loadedOptionalFiles = new SimpleBooleanProperty(true); public LocalModpackPage(WizardController controller) { super(controller); + btnOptionalFiles.setOnAction(ev -> controller.onNext(new OptionalFilesPage( + this::onInstall, + this::loadOptionalFiles, + loadingOptionalFiles, + loadedOptionalFiles, + new FilteredList<>(allFiles, ModpackFile::optional), + excludedFiles))); + HMCLGameRepository repository = controller.getSettings().get(ModpackPage.REPOSITORY); String name = controller.getSettings().get(MODPACK_NAME); @@ -103,8 +128,7 @@ public LocalModpackPage(WizardController controller) { Task.supplyAsync(() -> CompressingUtils.findSuitableEncoding(selectedFile)) .thenApplyAsync(encoding -> { charset = encoding; - manifest = ModpackHelper.readModpackManifest(selectedFile, encoding); - return manifest; + return ModpackHelper.readModpackManifest(selectedFile, encoding); }) .whenComplete(Schedulers.javafx(), (manifest, exception) -> { if (exception instanceof ManuallyCreatedModpackException) { @@ -126,7 +150,11 @@ public LocalModpackPage(WizardController controller) { LOG.warning("Failed to read modpack manifest", exception); Controllers.dialog(i18n("modpack.task.install.error"), i18n("message.error"), MessageDialogPane.MessageType.ERROR); Platform.runLater(controller::onEnd); + } else if (manifest == null) { + Controllers.dialog(i18n("modpack.task.install.error"), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + Platform.runLater(controller::onEnd); } else { + this.manifest = manifest; hideSpinner(); controller.getSettings().put(MODPACK_MANIFEST, manifest); nameProperty.set(manifest.getName()); @@ -139,6 +167,33 @@ public LocalModpackPage(WizardController controller) { } btnDescription.setVisible(StringUtils.isNotBlank(manifest.getDescription())); + + if (manifest.getManifest() instanceof ModpackManifest.SupportOptional supportOptional) { + allFiles.setAll(supportOptional.getFiles()); + if (allFiles.stream().anyMatch(ModpackFile::optional)) { + loadOptionalFiles(); + btnOptionalFiles.setVisible(true); + btnOptionalFiles.setManaged(true); + } + } + } + }).start(); + } + + private void loadOptionalFiles() { + Objects.requireNonNull(manifest); + loadingOptionalFiles.set(true); + loadedOptionalFiles.set(false); + Task.supplyAsync(() -> manifest.getManifest().getProvider().loadFiles(DownloadProviders.getDownloadProvider(), manifest.getManifest())) + .whenComplete(Schedulers.javafx(), (manifest1, exception) -> { + loadingOptionalFiles.set(false); + List files = ((ModpackManifest.SupportOptional) manifest1).getFiles(); + manifest.setManifest(manifest1); + allFiles.setAll(files); + if (files.stream().anyMatch(s -> s.optional() && (!s.addonQueried() || s.fileName() == null))) { + LOG.warning("Failed to load optional files"); + } else { + loadedOptionalFiles.set(true); } }).start(); } @@ -157,21 +212,28 @@ protected void onInstall() { i18n("install.name.invalid"), i18n("message.warning"), MessageDialogPane.MessageType.QUESTION) - .yesOrNo(() -> { - controller.getSettings().put(MODPACK_NAME, name); - controller.getSettings().put(MODPACK_CHARSET, charset); - controller.onFinish(); - }, () -> { + .yesOrNo(() -> finishInstall(name), () -> { // The user selects Cancel and does nothing. }) .build()); } else { - controller.getSettings().put(MODPACK_NAME, name); - controller.getSettings().put(MODPACK_CHARSET, charset); - controller.onFinish(); + finishInstall(name); } } + private void finishInstall(String name) { + controller.getSettings().put(MODPACK_NAME, name); + controller.getSettings().put(MODPACK_CHARSET, charset); + controller.getSettings().put(MODPACK_EXCLUDED_FILES, getExcludedFiles()); + controller.onFinish(); + } + + private @Nullable Set getExcludedFiles() { + if (allFiles.isEmpty()) + return null; + return Set.copyOf(excludedFiles); + } + protected void onDescribe() { if (manifest != null) Controllers.navigate(new WebPage(i18n("modpack.description"), manifest.getDescription())); @@ -183,4 +245,5 @@ protected void onDescribe() { public static final SettingsMap.Key MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET"); public static final SettingsMap.Key MODPACK_MANUALLY_CREATED = new SettingsMap.Key<>("MODPACK_MANUALLY_CREATED"); public static final SettingsMap.Key MODPACK_ICON_URL = new SettingsMap.Key<>("MODPACK_ICON_URL"); + public static final SettingsMap.Key> MODPACK_EXCLUDED_FILES = new SettingsMap.Key<>("MODPACK_EXCLUDED_FILES"); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java index 5788333781..e59c45db01 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackInstallWizardProvider.java @@ -90,6 +90,7 @@ private Task finishModpackInstallingAsync(SettingsMap settings) { String iconUrl = settings.get(LocalModpackPage.MODPACK_ICON_URL); Charset charset = settings.get(LocalModpackPage.MODPACK_CHARSET); boolean isManuallyCreated = settings.getOrDefault(LocalModpackPage.MODPACK_MANUALLY_CREATED, false); + var excludedFiles = settings.get(LocalModpackPage.MODPACK_EXCLUDED_FILES); if (isManuallyCreated) { return ModpackHelper.getInstallManuallyCreatedModpackTask(selected, name, charset); @@ -108,7 +109,7 @@ private Task finishModpackInstallingAsync(SettingsMap settings) { if (serverModpackManifest != null) { return ModpackHelper.getUpdateTask(repository, serverModpackManifest, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId))); } else { - return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId))); + return ModpackHelper.getUpdateTask(repository, selected, modpack.getEncoding(), instanceId, ModpackHelper.readModpackConfiguration(repository.getLayout().getModpackConfigurationFile(instanceId)), excludedFiles); } } catch (UnsupportedModpackException | ManuallyCreatedModpackException e) { Controllers.dialog(i18n("modpack.unsupported"), i18n("message.error"), MessageType.ERROR); @@ -123,7 +124,7 @@ private Task finishModpackInstallingAsync(SettingsMap settings) { return ModpackHelper.getInstallTask(repository, serverModpackManifest, instanceId, modpack) .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId))); } else { - return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl) + return ModpackHelper.getInstallTask(repository, selected, instanceId, modpack, iconUrl, excludedFiles) .thenRunAsync(Schedulers.javafx(), () -> repository.setSelectedInstance(repository.getInstance(instanceId))); } } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackPage.java index e36ed15dd9..1f1dd128a9 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/ModpackPage.java @@ -22,6 +22,7 @@ import javafx.beans.property.StringProperty; import javafx.geometry.Pos; import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; import javafx.scene.layout.VBox; import org.jackhuang.hmcl.game.HMCLGameRepository; import org.jackhuang.hmcl.setting.GameDirectory; @@ -49,6 +50,7 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage { protected final JFXTextField txtModpackName; protected final JFXButton btnInstall; protected final JFXButton btnDescription; + protected final JFXButton btnOptionalFiles; protected ModpackPage(WizardController controller) { this.controller = controller; @@ -66,7 +68,6 @@ protected ModpackPage(WizardController controller) { txtModpackName = new JFXTextField(); txtModpackName.setPrefWidth(300); - // BorderPane.setMargin(txtModpackName, new Insets(0, 0, 8, 32)); BorderPane.setAlignment(txtModpackName, Pos.CENTER_RIGHT); archiveNamePane.setRight(txtModpackName); } @@ -95,10 +96,19 @@ protected ModpackPage(WizardController controller) { btnDescription.setOnAction(e -> onDescribe()); descriptionPane.setLeft(btnDescription); + var installHBox = new HBox(8); + btnOptionalFiles = FXUtils.newRaisedButton(i18n("modpack.optional_files")); + btnOptionalFiles.setVisible(false); + btnOptionalFiles.setManaged(false); + installHBox.getChildren().add(btnOptionalFiles); + btnInstall = FXUtils.newRaisedButton(i18n("button.install")); btnInstall.setOnAction(e -> onInstall()); - btnInstall.disableProperty().bind(createBooleanBinding(() -> !txtModpackName.validate(), txtModpackName.textProperty())); - descriptionPane.setRight(btnInstall); + var nameInvalid = createBooleanBinding(() -> !txtModpackName.validate(), txtModpackName.textProperty()); + btnInstall.disableProperty().bind(nameInvalid); + btnOptionalFiles.disableProperty().bind(nameInvalid); + installHBox.getChildren().add(btnInstall); + descriptionPane.setRight(installHBox); } componentList.getContent().setAll( diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/OptionalFilesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/OptionalFilesPage.java new file mode 100644 index 0000000000..38fbb1a43c --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/OptionalFilesPage.java @@ -0,0 +1,322 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.ui.download; + +import com.jfoenix.controls.JFXButton; +import com.jfoenix.controls.JFXCheckBox; +import com.jfoenix.controls.JFXDialogLayout; +import com.jfoenix.controls.JFXListView; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.beans.value.ObservableBooleanValue; +import javafx.collections.ObservableList; +import javafx.collections.ObservableSet; +import javafx.collections.SetChangeListener; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.control.Label; +import javafx.scene.image.ImageView; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.Region; +import javafx.scene.layout.VBox; +import org.jackhuang.hmcl.addon.RemoteAddon; +import org.jackhuang.hmcl.modpack.ModpackFile; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.construct.ComponentList; +import org.jackhuang.hmcl.ui.construct.DialogCloseEvent; +import org.jackhuang.hmcl.ui.construct.JFXHyperlink; +import org.jackhuang.hmcl.ui.construct.MDListCell; +import org.jackhuang.hmcl.ui.construct.SpinnerPane; +import org.jackhuang.hmcl.ui.construct.TwoLineListItem; +import org.jackhuang.hmcl.ui.wizard.WizardPage; +import org.jackhuang.hmcl.util.SettingsMap; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; + +/// Wizard page that lets the user choose which optional modpack files to install. +@NotNullByDefault +public class OptionalFilesPage extends SpinnerPane implements WizardPage { + + /// Fixed row height used to size [#body] to its items. + private static final double CELL_HEIGHT = 48; + + /// Maximum list viewport height before scrolling. + private static final double MAX_LIST_HEIGHT = 320; + + /// Keys of files the user unchecked; empty means all optional files will be installed. + private final ObservableSet excludedFiles; + + /// Per-file selected state mirrored from [#excludedFiles] (`true` means install / not excluded). + private final Map selectedByKey = new HashMap<>(); + + /// Whether [#selectedByKey] is being updated from an [#excludedFiles] set change. + private boolean updatingFromSet; + + /// List view showing optional files. + private final JFXListView body = new JFXListView<>(); + + /// Creates the optional-files selection page. + /// + /// @param install callback that finishes the install wizard + /// @param retry callback that reloads optional-file metadata + /// @param loading whether remote metadata is still loading + /// @param successful whether remote metadata loaded successfully + /// @param optionalFiles optional files to display + /// @param excludedFiles mutable set of unchecked optional file keys + public OptionalFilesPage( + Runnable install, + Runnable retry, + ObservableBooleanValue loading, + ObservableBooleanValue successful, + ObservableList optionalFiles, + ObservableSet excludedFiles) { + this.excludedFiles = excludedFiles; + excludedFiles.addListener((SetChangeListener) change -> { + updatingFromSet = true; + try { + if (change.wasRemoved()) { + BooleanProperty selected = selectedByKey.get(change.getElementRemoved()); + if (selected != null) + selected.set(true); + } + if (change.wasAdded()) { + BooleanProperty selected = selectedByKey.get(change.getElementAdded()); + if (selected != null) + selected.set(false); + } + } finally { + updatingFromSet = false; + } + }); + + VBox borderPane = new VBox(); + borderPane.setAlignment(Pos.CENTER); + borderPane.setMaxHeight(Region.USE_PREF_SIZE); + FXUtils.setLimitWidth(borderPane, 500); + + ComponentList componentList = new ComponentList(); + + Label lblRetry = new Label(i18n("modpack.retry_optional_files")); + lblRetry.setOnMouseClicked(e -> retry.run()); + + VBox tail = new VBox(); + { + var descPane = new BorderPane(); + HBox selectionButtons = new HBox(8); + JFXButton btnSelectAll = FXUtils.newBorderButton(i18n("button.select_all")); + btnSelectAll.setOnAction(e -> excludedFiles.clear()); + JFXButton btnClear = FXUtils.newBorderButton(i18n("button.clear")); + btnClear.setOnAction(e -> { + excludedFiles.clear(); + for (ModpackFile file : optionalFiles) { + excludedFiles.add(file.key()); + } + }); + selectionButtons.getChildren().setAll(btnSelectAll, btnClear); + descPane.setLeft(selectionButtons); + + var btnInstall = FXUtils.newRaisedButton(i18n("button.install")); + descPane.setRight(btnInstall); + btnInstall.setOnAction(e -> install.run()); + tail.getChildren().add(descPane); + } + + // ListView defaults to a large preferred height (~400) regardless of item count, which + // leaves empty viewport space when few optional files exist. Size exactly to content — + // do not add extra pixels, or a blank strip appears under the last row. + body.setFixedCellSize(CELL_HEIGHT); + body.prefHeightProperty().bind(Bindings.createDoubleBinding( + () -> Math.min(MAX_LIST_HEIGHT, optionalFiles.size() * CELL_HEIGHT + 2), + optionalFiles)); + body.setMaxHeight(Region.USE_PREF_SIZE); + body.setCellFactory(it -> new OptionalFileEntry(body)); + body.setItems(optionalFiles); + + Runnable refreshContent = () -> { + if (successful.get()) { + componentList.getContent().setAll(body, tail); + } else { + componentList.getContent().setAll(lblRetry, body, tail); + } + }; + refreshContent.run(); + successful.addListener((obs, oldVal, newVal) -> refreshContent.run()); + + borderPane.getChildren().setAll(componentList); + setContent(borderPane); + loadingProperty().bind(loading); + } + + /// Returns the selected property for `key`, creating and wiring it on first use. + /// + /// @param key the file exclusion key + /// @return a property that is `true` when the file is selected for install + private BooleanProperty selectedProperty(String key) { + return selectedByKey.computeIfAbsent(key, k -> { + BooleanProperty selected = new SimpleBooleanProperty(!excludedFiles.contains(k)); + selected.addListener((obs, wasSelected, isSelected) -> { + if (updatingFromSet) + return; + if (isSelected) { + excludedFiles.remove(k); + } else { + excludedFiles.add(k); + } + }); + return selected; + }); + } + + /// List cell for one optional file with checkbox and detail button. + private final class OptionalFileEntry extends MDListCell { + private final JFXCheckBox checkBox = new JFXCheckBox(); + private final TwoLineListItem content = new TwoLineListItem(); + private final JFXButton infoButton = new JFXButton(); + private final HBox container = new HBox(8); + + /// Currently bidirectionally bound selected property, or `null` when unbound. + private @Nullable BooleanProperty boundSelected = null; + + /// Creates a cell bound to `listView`. + /// + /// @param listView the owning list view + public OptionalFileEntry(JFXListView listView) { + super(listView); + container.setPickOnBounds(false); + container.setAlignment(Pos.CENTER_LEFT); + HBox.setHgrow(content, Priority.ALWAYS); + content.setMouseTransparent(true); + setSelectable(); + container.getChildren().setAll(checkBox, content); + + infoButton.getStyleClass().add("toggle-icon4"); + infoButton.setGraphic(SVG.INFO.createIcon()); + container.getChildren().add(infoButton); + getContainer().getChildren().setAll(container); + } + + @Override + protected void updateControl(ModpackFile item, boolean empty) { + if (empty) { + if (boundSelected != null) { + checkBox.selectedProperty().unbindBidirectional(boundSelected); + boundSelected = null; + } + return; + } + String name = item.fileName(); + if (name != null) { + content.setTitle(name); + } else { + content.setTitle(i18n("modpack.unknown_optional_file")); + } + RemoteAddon addon = item.remoteAddon(); + if (addon != null) { + content.setSubtitle(addon.title()); + infoButton.setOnMouseClicked(e -> Controllers.dialog(new ModInfo(addon))); + infoButton.setManaged(true); + infoButton.setVisible(true); + } else { + content.setSubtitle(""); + infoButton.setOnMouseClicked(null); + infoButton.setManaged(false); + infoButton.setVisible(false); + } + BooleanProperty selected = OptionalFilesPage.this.selectedProperty(item.key()); + if (boundSelected != selected) { + if (boundSelected != null) + checkBox.selectedProperty().unbindBidirectional(boundSelected); + checkBox.selectedProperty().bindBidirectional(selected); + boundSelected = selected; + } + } + } + + /// Dialog showing remote addon details for an optional file. + private static final class ModInfo extends JFXDialogLayout { + /// Creates a detail dialog for `addon`. + /// + /// @param addon the remote addon + public ModInfo(RemoteAddon addon) { + HBox container = new HBox(8); + SpinnerPane spinnerPane = new SpinnerPane(); + ImageView imageView = new ImageView(); + imageView.setFitHeight(32); + imageView.setFitWidth(32); + spinnerPane.setContent(imageView); + spinnerPane.setPrefSize(32, 32); + spinnerPane.setLoading(true); + CompletableFuture.supplyAsync(() -> { + try { + return FXUtils.getRemoteImageTask(addon.iconUrl(), 32, 32, true, true).run(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, Schedulers.io()).thenAcceptAsync((image) -> { + imageView.setImage(image); + spinnerPane.setLoading(false); + }, Schedulers.javafx()); + container.getChildren().add(spinnerPane); + + TwoLineListItem title = new TwoLineListItem(); + title.setTitle(addon.title()); + title.setSubtitle(addon.author()); + container.getChildren().add(title); + setHeading(container); + + Label description = new Label(addon.description()); + description.setWrapText(true); + description.setPadding(new Insets(8, 0, 0, 0)); + setBody(description); + + JFXHyperlink pageButton = new JFXHyperlink(i18n("mods.url")); + pageButton.setOnAction(e -> FXUtils.openLink(addon.pageUrl())); + getActions().add(pageButton); + + JFXButton okButton = new JFXButton(); + okButton.getStyleClass().add("dialog-accept"); + okButton.setText(i18n("button.ok")); + okButton.setOnAction(e -> fireEvent(new DialogCloseEvent())); + getActions().add(okButton); + + onEscPressed(this, okButton::fire); + } + } + + @Override + public String getTitle() { + return i18n("modpack.optional_files"); + } + + @Override + public void cleanup(SettingsMap settings) { + } +} diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 0ae0de505f..1a223cdfa4 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1092,6 +1092,10 @@ -fx-border-width: 0 0 1 0; } +.md-list-cell:last { + -fx-border-width: 0; +} + .md-list-cell:selected { -fx-background-color: -monet-secondary-container; } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 672b254bb3..dcf506f84a 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -1025,6 +1025,9 @@ modpack.installing=Installing modpack modpack.installing.given=Installing %s modpack modpack.invalid=Invalid modpack, you can try downloading it again. modpack.mismatched_type=Modpack type mismatched, the current instance is a(n) %1$s type, but the provided one is %2$s type. +modpack.optional_files=Optional Files +modpack.retry_optional_files=Failed to load optional files. Click here to retry. +modpack.unknown_optional_file=Unknown file modpack.name=Modpack Name modpack.origin=Source modpack.origin.url=Official Website diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 3b1e9c7f9a..58cde3acab 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -961,6 +961,9 @@ modpack.installing=جارٍ تثبيت حزمة المودات modpack.installing.given=جارٍ تثبيت حزمة المودات %s modpack.invalid=حزمة مودات غير صالحة، يمكنك محاولة تنزيلها مجدداً. modpack.mismatched_type=نوع حزمة المودات غير متطابق، النسخة الحالية من نوع %1$s، لكن الحزمة المقدمة من نوع %2$s. +modpack.optional_files=ملفات اختيارية +modpack.retry_optional_files=فشل تحميل الملفات الاختيارية. انقر لإعادة المحاولة. +modpack.unknown_optional_file=ملف غير معروف modpack.name=اسم حزمة المودات modpack.origin=المصدر modpack.origin.url=الموقع الرسمي diff --git a/HMCL/src/main/resources/assets/lang/I18N_de.properties b/HMCL/src/main/resources/assets/lang/I18N_de.properties index 059c0fe977..947f196e3d 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_de.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_de.properties @@ -1022,6 +1022,9 @@ modpack.installing=Modpack wird installiert modpack.installing.given=Installiere %s Modpack modpack.invalid=Ungültiges Modpack, Sie können versuchen, es erneut herunterzuladen. modpack.mismatched_type=Modpack-Typ stimmt nicht überein, die aktuelle Instanz ist ein(e) %1$s Typ, aber der bereitgestellte Typ ist %2$s. +modpack.optional_files=Optionale Dateien +modpack.retry_optional_files=Optionale Dateien konnten nicht geladen werden. Hier klicken, um es erneut zu versuchen. +modpack.unknown_optional_file=Unbekannte Datei modpack.name=Modpack-Name modpack.origin=Quelle modpack.origin.url=Offizielle Website diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index 4d1c14ae69..de61dc86a6 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -913,6 +913,9 @@ modpack.installing=Instalando modpack modpack.installing.given=Instalando %s modpack modpack.invalid=Modpack inválido, puede intentar volver a descargarlo. modpack.mismatched_type=Tipo de modpack erróneo, la instancia actual es del tipo %s, pero la proporcionada es del tipo %s. +modpack.optional_files=Archivos opcionales +modpack.retry_optional_files=No se pudieron cargar los archivos opcionales. Haz clic para reintentar. +modpack.unknown_optional_file=Archivo desconocido modpack.name=Nombre del modpack modpack.origin=Fuente modpack.origin.url=Sitio web oficial diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index 5e4fb6d3c3..f072caee35 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -625,6 +625,9 @@ modpack.files.servers_dat=サーバーリスト modpack.installing=modpackのインストール modpack.invalid=無効なmodpackファイル。 modpack.mismatched_type=不適切なmodpackタイプ、現在のゲームは %s modpackですが、更新ファイルは %s modpackです。 +modpack.optional_files=オプションファイル +modpack.retry_optional_files=オプションファイルの読み込みに失敗しました。クリックして再試行してください。 +modpack.unknown_optional_file=不明なファイル modpack.name=Modpack名 modpack.origin=Origin modpack.origin.url=公式ウェブサイト diff --git a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties index 2f517b5898..230c960a73 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_lzh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_lzh.properties @@ -744,6 +744,9 @@ modpack.installing=裝改囊集 modpack.installing.given=裝 %s 改囊集 modpack.invalid=無效之改囊集新之案。殆引之誤也。 modpack.mismatched_type=不合改囊集之類。夫戲爲「%s」改囊集,顧所供改囊集之案爲「%s」改囊集。\n君可求助於右上之鈕。 +modpack.optional_files=可選之檔 +modpack.retry_optional_files=可選檔載入敗,點此再試 +modpack.unknown_optional_file=未知之檔 modpack.name=改囊集名 modpack.origin=源 modpack.origin.url=官網 diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 49735dfe6d..53e09ecb26 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -906,6 +906,9 @@ modpack.installing=Установка модпак modpack.installing.given=Установка модпак %s modpack.invalid=Неверный модпак, попробуйте скачать его заново. modpack.mismatched_type=Несоответствие типа модпака, текущий сборка имеет тип %s, но предоставленный сборка имеет тип %s. +modpack.optional_files=Необязательные файлы +modpack.retry_optional_files=Не удалось загрузить необязательные файлы. Нажмите, чтобы повторить. +modpack.unknown_optional_file=Неизвестный файл modpack.name=Имя модпака modpack.origin=Источник modpack.origin.url=Официальный сайт diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 25044542c8..ab234bd48f 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -983,6 +983,9 @@ modpack.installing=Встановлення модпака modpack.installing.given=Встановлення модпака %s modpack.invalid=Недійсний модпак, ви можете спробувати завантажити його знову. modpack.mismatched_type=Тип модпака не відповідає, поточний екземпляр має тип %s, але наданий має тип %s. +modpack.optional_files=Необов’язкові файли +modpack.retry_optional_files=Не вдалося завантажити необов’язкові файли. Натисніть, щоб повторити. +modpack.unknown_optional_file=Невідомий файл modpack.name=Назва модпака modpack.origin=Джерело modpack.origin.url=Офіційний сайт diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 48e4524a3d..58d34ba25e 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -837,6 +837,9 @@ modpack.installing=安裝模組包 modpack.installing.given=安裝 %s 模組包 modpack.invalid=無效的模組包升級檔案。可能是下載時出現問題。 modpack.mismatched_type=模組包類型不符。目前遊戲是「%s」模組包,但是提供的模組包更新檔案是「%s」模組包。 +modpack.optional_files=可選檔案 +modpack.retry_optional_files=可選檔案載入失敗,點擊重試 +modpack.unknown_optional_file=未知檔案 modpack.name=模組包名稱 modpack.origin=來源 modpack.origin.url=官方網站 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 cc1c47a2dc..ac5bae34da 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -842,6 +842,9 @@ modpack.installing=安装整合包 modpack.installing.given=安装 %s 整合包 modpack.invalid=无效的整合包升级文件。可能是下载时出现问题。 modpack.mismatched_type=整合包类型不匹配。当前游戏为“%s”整合包,但是提供的整合包更新文件为“%s”整合包。\n如遇到问题,你可以点击右上角帮助按钮进行求助。 +modpack.optional_files=可选文件 +modpack.retry_optional_files=可选文件加载失败,点击重试 +modpack.unknown_optional_file=未知文件 modpack.name=整合包名称 modpack.origin=来源 modpack.origin.url=官方网站 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 2187a4ca0b..45b76285c3 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 @@ -213,8 +213,15 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion @Override public Optional getRemoteVersionByLocalFile(Path file) throws IOException { - String sha1 = DigestUtils.digestToString("SHA-1", file); + return getRemoteVersionBySHA1(DigestUtils.digestToString("SHA-1", file)); + } + /// Looks up a Modrinth version by file SHA-1 hash. + /// + /// @param sha1 the SHA-1 digest of the file + /// @return the matching remote version, or empty when not found + /// @throws IOException if the Modrinth request fails for a reason other than 404 / missing file + public Optional getRemoteVersionBySHA1(String sha1) throws IOException { SEMAPHORE.acquireUninterruptibly(); try { ProjectVersion projectVersion = HttpRequest.GET(PREFIX + "/v2/version_file/" + sha1, diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/Modpack.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/Modpack.java index d0dd38d9f5..b170b563cb 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/Modpack.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/Modpack.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.game.GameInstanceID; import org.jackhuang.hmcl.task.Task; +import org.jetbrains.annotations.Nullable; import java.nio.charset.Charset; import java.nio.file.Path; @@ -120,7 +121,21 @@ public Modpack setManifest(ModpackManifest manifest) { return this; } - public abstract Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl); + /// Creates the install task for this modpack. + /// + /// @param dependencyManager the dependency manager + /// @param zipFile the modpack archive + /// @param instanceId the target instance id + /// @param iconUrl the optional icon URL, or `null` + /// @param excludedFiles keys of optional files the user chose not to install; `null` or empty means + /// install all files. When non-null, must not contain `null` elements. + /// @return the install task + public abstract Task getInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + GameInstanceID instanceId, + String iconUrl, + @Nullable Set excludedFiles); public static boolean acceptFile(String path, List blackList, List whiteList) { if (path.isEmpty()) diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackFile.java new file mode 100644 index 0000000000..2f2efd8209 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackFile.java @@ -0,0 +1,63 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2026 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.modpack; + +import org.jackhuang.hmcl.addon.RemoteAddon; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Nullable; + +/// A modpack file entry that may be marked optional by the pack author. +@NotNullByDefault +public interface ModpackFile { + + /// Returns a stable identity for this file used when persisting exclusion choices. + /// + /// @return the exclusion key + String key(); + + /// Returns the file name, or `null` when it has not been resolved yet. + /// + /// @return the file name, or `null` + @Nullable + String fileName(); + + /// Returns whether this file is optional on the client side. + /// + /// @return `true` when the client may skip this file + boolean optional(); + + /// Returns the path of the file relative to the instance run directory, or `null` when unknown. + /// + /// @return the relative path, or `null` + @Nullable + String path(); + + /// Returns the remote addon metadata for this file when [#addonQueried()] is `true`. + /// + /// @return the remote addon, or `null` when not found or not yet queried + @Nullable + RemoteAddon remoteAddon(); + + /// Returns whether remote addon metadata has been queried for this file. + /// + /// When `false`, [#remoteAddon()] is unset and a query has not been attempted yet. + /// When `true`, [#remoteAddon()] is the queried result (`null` means not found). + /// + /// @return `true` when remote addon metadata has been queried + boolean addonQueried(); +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackManifest.java index e065471652..24538a1d5d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackManifest.java @@ -17,6 +17,28 @@ */ package org.jackhuang.hmcl.modpack; +import org.jetbrains.annotations.NotNullByDefault; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.List; + +/// Format-specific modpack manifest metadata. +@NotNullByDefault public interface ModpackManifest { + + /// Returns the provider that understands this manifest. + /// + /// @return the modpack provider ModpackProvider getProvider(); + + /// Marker for manifests that expose optional file entries. + @NotNullByDefault + interface SupportOptional { + + /// Returns all files declared by this manifest, including required and optional ones. + /// + /// @return the modpack files + @Unmodifiable + List getFiles(); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java index c4c9c4bb02..68ee26a295 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/ModpackProvider.java @@ -20,6 +20,7 @@ import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveReader; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.LaunchOptions; import org.jackhuang.hmcl.task.Task; @@ -29,6 +30,7 @@ import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; +import java.util.Set; /// Provides format-specific operations for reading, installing, updating, and completing modpacks. @NotNullByDefault @@ -52,9 +54,16 @@ public interface ModpackProvider { /// @param instance the registered instance to update /// @param zipFile the modpack archive /// @param modpack the parsed modpack + /// @param excludedFiles keys of optional files the user chose not to install; `null` or empty means + /// install all files. When non-null, must not contain `null` elements. /// @return the update task /// @throws MismatchedModpackTypeException if the parsed manifest belongs to another provider - Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException; + Task createUpdateTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + Path zipFile, + Modpack modpack, + @Nullable Set excludedFiles) throws MismatchedModpackTypeException; /// Reads this provider's manifest from an opened modpack archive. /// @@ -72,4 +81,15 @@ public interface ModpackProvider { /// @param builder the launch options builder to update default void injectLaunchOptions(String modpackConfigurationJson, LaunchOptions.Builder builder) { } + + /// Enriches a manifest with remote metadata for optional files (file names, URLs, addons). + /// The type of the result manifest must be same as the original one. + /// When the remote metadata can not be fetched, the original files are returned. + /// + /// @param downloadProvider the download provider used for remote queries + /// @param manifest the parsed manifest + /// @return the enriched manifest, or `manifest` when no enrichment is needed + default ModpackManifest loadFiles(DownloadProvider downloadProvider, ModpackManifest manifest) { + return manifest; + } } 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 aadc19a153..44deeefee9 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 @@ -18,13 +18,13 @@ package org.jackhuang.hmcl.modpack.curse; import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.addon.RemoteAddon; +import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.game.DefaultGameInstance; -import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.modpack.ModpackCompletionException; -import org.jackhuang.hmcl.addon.RemoteAddon; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.StringUtils; @@ -38,9 +38,10 @@ import java.nio.file.Path; import java.util.Collection; import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; -import java.util.stream.Collectors; import java.util.stream.Stream; import static org.jackhuang.hmcl.util.logging.Logger.LOG; @@ -61,6 +62,9 @@ public final class CurseCompletionTask extends Task { /// The manifest supplied by the caller or loaded from disk, if available. private @Nullable CurseManifest manifest; + /// Keys of optional files the user chose not to install; `null` means install all. + private @Nullable Set excludedFiles; + /// Download tasks produced during [#execute()]. private List> dependencies = List.of(); @@ -78,7 +82,7 @@ public final class CurseCompletionTask extends Task { /// @param dependencyManager the dependency manager /// @param instance the registered instance to complete public CurseCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { - this(dependencyManager, instance, null); + this(dependencyManager, instance, null, null); } /// Creates a task that completes the installed CurseForge modpack using an optional manifest. @@ -86,21 +90,33 @@ public CurseCompletionTask(DefaultDependencyManager dependencyManager, DefaultGa /// @param dependencyManager the dependency manager /// @param instance the registered instance to complete /// @param manifest the CurseForge manifest, or `null` to read it from disk + /// @param excludedFiles keys of optional files the user chose not to install; `null` means install all. + /// When non-null, must not contain `null` elements. public CurseCompletionTask( DefaultDependencyManager dependencyManager, DefaultGameInstance instance, - @Nullable CurseManifest manifest) { + @Nullable CurseManifest manifest, + @Nullable Set excludedFiles) { dependencyManager.validateGameInstance(instance); this.dependency = dependencyManager; this.instance = instance; this.modManager = instance.getModManager(); this.manifest = manifest; + this.excludedFiles = excludedFiles == null ? null : Set.copyOf(excludedFiles); if (manifest == null) try { - Path manifestFile = instance.getInstanceRoot().resolve("manifest.json"); + Path root = instance.getInstanceRoot(); + Path manifestFile = root.resolve("manifest.json"); if (Files.exists(manifestFile)) this.manifest = JsonUtils.fromJsonFile(manifestFile, CurseManifest.class); + Path excludedFile = root.resolve("excluded.json"); + if (Files.exists(excludedFile)) { + this.excludedFiles = Set.copyOf(Objects.requireNonNull( + JsonUtils.fromJsonFile(excludedFile, JsonUtils.listTypeOf(String.class)))); + } else { + this.excludedFiles = null; + } } catch (Exception e) { LOG.warning("Unable to read CurseForge modpack manifest.json", e); } @@ -148,8 +164,13 @@ public void execute() throws Exception { return file; } }) - .collect(Collectors.toList())); + .toList()); JsonUtils.writeToJsonFile(root.resolve("manifest.json"), newManifest); + if (excludedFiles != null) { + JsonUtils.writeToJsonFile( + root.resolve("excluded.json"), + List.copyOf(excludedFiles)); + } Path versionRoot = instance.getInstanceRoot(); Path resourcePacksRoot = versionRoot.resolve("resourcepacks"); @@ -158,6 +179,7 @@ public void execute() throws Exception { dependencies = newManifest.files() .stream().parallel() .filter(f -> f.fileName() != null) + .filter(f -> excludedFiles == null || !excludedFiles.contains(f.key())) .flatMap(f -> { try { Path path = guessFilePath(f, dependency.getDownloadProvider(), resourcePacksRoot, shaderPacksRoot); @@ -168,7 +190,7 @@ public void execute() throws Exception { var task = new FileDownloadTask(f.url(), path); task.setCacheRepository(dependency.getCacheRepository()); task.setCaching(true); - return Stream.of(task.withCounter("hmcl.modpack.download")); + return Stream.>of(task.withCounter("hmcl.modpack.download")); } catch (IOException e) { LOG.warning("Could not query api.curseforge.com for mod: " + f.projectID() + ", " + f.fileID(), e); return Stream.empty(); // Ignore this file. @@ -176,7 +198,7 @@ public void execute() throws Exception { updateProgress(finished.incrementAndGet(), newManifest.files().size()); } }) - .collect(Collectors.toList()); + .toList(); if (!dependencies.isEmpty()) { getProperties().put("total", dependencies.size()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java index 0c68d91f48..600924796a 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseInstallTask.java @@ -61,6 +61,9 @@ public final class CurseInstallTask extends Task { /// Previous modpack configuration when updating, or `null` for a new installation. private final @Nullable ModpackConfiguration config; + /// Keys of optional files the user chose not to install; `null` or empty means install all. + private final @Nullable Set excludedFiles; + /// Validated extension of the scheduled icon download, or `null` when no icon is scheduled. private @Nullable String iconExt; @@ -77,6 +80,8 @@ public final class CurseInstallTask extends Task { /// @param manifest the CurseForge manifest /// @param instanceId the id of the new instance /// @param iconUrl the optional icon URL, or `null` + /// @param excludedFiles keys of optional files the user chose not to install; `null` or empty means + /// install all files. When non-null, must not contain `null` elements. /// @throws IllegalStateException if the target cannot be reserved or another repository draft /// is open public CurseInstallTask( @@ -85,8 +90,9 @@ public CurseInstallTask( Modpack modpack, CurseManifest manifest, GameInstanceID instanceId, - @Nullable String iconUrl) { - this(dependencyManager, zipFile, modpack, manifest, instanceId, null, iconUrl); + @Nullable String iconUrl, + @Nullable Set excludedFiles) { + this(dependencyManager, zipFile, modpack, manifest, instanceId, null, iconUrl, excludedFiles); } /// Creates a task that updates an existing CurseForge modpack instance. @@ -97,6 +103,8 @@ public CurseInstallTask( /// @param manifest the CurseForge manifest /// @param instance the existing instance to update /// @param iconUrl the optional icon URL, or `null` + /// @param excludedFiles keys of optional files the user chose not to install; `null` or empty means + /// install all files. When non-null, must not contain `null` elements. /// @throws IllegalArgumentException if `instance` belongs to another repository, has no /// modpack configuration, or records another provider type /// @throws IllegalStateException if `instance` is not the exact currently published object @@ -107,8 +115,9 @@ public CurseInstallTask( Modpack modpack, CurseManifest manifest, DefaultGameInstance instance, - @Nullable String iconUrl) { - this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance, iconUrl); + @Nullable String iconUrl, + @Nullable Set excludedFiles) { + this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance, iconUrl, excludedFiles); } /// Creates a CurseForge installation task in the mode selected by `updateTarget`. @@ -120,6 +129,8 @@ public CurseInstallTask( /// @param instanceId the target instance id /// @param updateTarget the existing instance selecting update mode, or `null` for install /// @param iconUrl the optional icon URL, or `null` + /// @param excludedFiles keys of optional files the user chose not to install; `null` or empty means + /// install all files. When non-null, must not contain `null` elements. /// @throws IllegalArgumentException if an update target has no compatible configuration /// @throws IllegalStateException if the target cannot be reserved, an update target is not /// the exact published object, or another draft is open @@ -130,7 +141,8 @@ private CurseInstallTask( CurseManifest manifest, GameInstanceID instanceId, @Nullable DefaultGameInstance updateTarget, - @Nullable String iconUrl) { + @Nullable String iconUrl, + @Nullable Set excludedFiles) { this.dependencyManager = dependencyManager; this.zipFile = zipFile; this.modpack = modpack; @@ -138,6 +150,7 @@ private CurseInstallTask( this.instanceId = instanceId; this.updateTarget = updateTarget; this.iconUrl = iconUrl; + this.excludedFiles = excludedFiles == null ? null : Set.copyOf(excludedFiles); this.repository = dependencyManager.getGameRepository(); this.run = repository.getLayout().getInstanceRoot(instanceId); @@ -282,6 +295,6 @@ public void execute() throws Exception { } // The game builder runs as a dependent and registers the instance before this phase. - dependencies.add(new CurseCompletionTask(dependencyManager, repository.getInstance(instanceId), manifest)); + dependencies.add(new CurseCompletionTask(dependencyManager, repository.getInstance(instanceId), manifest, excludedFiles)); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifest.java index 2fa5347d18..86317da4c7 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifest.java @@ -18,6 +18,7 @@ package org.jackhuang.hmcl.modpack.curse; import com.google.gson.annotations.SerializedName; +import org.jackhuang.hmcl.modpack.ModpackFile; import org.jackhuang.hmcl.modpack.ModpackManifest; import org.jackhuang.hmcl.modpack.ModpackProvider; import org.jackhuang.hmcl.util.gson.JsonSerializable; @@ -34,12 +35,18 @@ public record CurseManifest(@SerializedName("manifestType") String manifestType, @SerializedName("author") String author, @SerializedName("overrides") String overrides, @SerializedName("minecraft") CurseManifestMinecraft minecraft, - @SerializedName("files") @Unmodifiable List files) implements ModpackManifest { + @SerializedName("files") @Unmodifiable List files) + implements ModpackManifest, ModpackManifest.SupportOptional { public CurseManifest setFiles(List files) { return new CurseManifest(manifestType, manifestVersion, name, version, author, overrides, minecraft, files); } + @Override + public List getFiles() { + return files; + } + @Override public ModpackProvider getProvider() { return CurseModpackProvider.INSTANCE; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java index b8edc77f56..c54b21d18e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseManifestFile.java @@ -19,19 +19,52 @@ import com.google.gson.JsonParseException; import com.google.gson.annotations.SerializedName; +import org.jackhuang.hmcl.addon.RemoteAddon; +import org.jackhuang.hmcl.modpack.ModpackFile; import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.Validation; import org.jetbrains.annotations.Nullable; import java.util.Objects; +/// A CurseForge modpack file entry. +/// /// @author huangyuhui @JsonSerializable -public record CurseManifestFile(@SerializedName("projectID") int projectID, - @SerializedName("fileID") int fileID, - @SerializedName("fileName") String fileName, - @SerializedName("url") String url, - @SerializedName("required") boolean required) implements Validation { +public record CurseManifestFile( + @SerializedName("projectID") int projectID, + @SerializedName("fileID") int fileID, + @SerializedName("fileName") @Nullable String fileName, + @SerializedName("url") @Nullable String url, + @SerializedName("required") boolean required, + @Nullable RemoteAddon remoteAddon, + boolean addonQueried) implements Validation, ModpackFile { + + /// Creates a file entry without remote addon metadata. + /// + /// @param projectID the project id + /// @param fileID the file id + /// @param fileName the file name, or `null` + /// @param url the download URL, or `null` + /// @param required whether the file is required + public CurseManifestFile(int projectID, int fileID, @Nullable String fileName, @Nullable String url, boolean required) { + this(projectID, fileID, fileName, url, required, null, false); + } + + @Override + public String key() { + return "curseforge:" + projectID + ":" + fileID; + } + + @Override + public boolean optional() { + return !required(); + } + + @Override + public @Nullable String path() { + return fileName != null ? "mods/" + fileName : null; + } @Override public void validate() throws JsonParseException { @@ -39,9 +72,11 @@ public void validate() throws JsonParseException { throw new JsonParseException("Missing Project ID or File ID."); } + /// Returns the download URL, deriving a ForgeCDN URL when the manifest omits it. + /// + /// @return the download URL, or `null` when the file name is also missing @Override - @Nullable - public String url() { + public @Nullable String url() { if (url == null) { return fileName != null ? String.format("https://edge.forgecdn.net/files/%d/%d/%s", fileID / 1000, fileID % 1000, fileName) @@ -51,12 +86,28 @@ public String url() { } } + /// Returns a copy with a resolved file name. + /// + /// @param fileName the resolved file name + /// @return the updated entry public CurseManifestFile withFileName(String fileName) { - return new CurseManifestFile(projectID, fileID, fileName, url, required); + return new CurseManifestFile(projectID, fileID, fileName, url, required, remoteAddon, addonQueried); } + /// Returns a copy with a resolved download URL. + /// + /// @param url the download URL + /// @return the updated entry public CurseManifestFile withURL(String url) { - return new CurseManifestFile(projectID, fileID, fileName, url, required); + return new CurseManifestFile(projectID, fileID, fileName, url, required, remoteAddon, addonQueried); + } + + /// Returns a copy marked as queried, with the given remote addon metadata. + /// + /// @param remoteAddon the remote addon, or `null` when not found + /// @return the updated entry + public CurseManifestFile withAddon(@Nullable RemoteAddon remoteAddon) { + return new CurseManifestFile(projectID, fileID, fileName, url, required, remoteAddon, true); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java index 32dcd04a72..fc2d346581 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/curse/CurseModpackProvider.java @@ -20,21 +20,27 @@ import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveEntry; import kala.compress.archivers.zip.ZipArchiveReader; +import org.jackhuang.hmcl.addon.RemoteAddon; +import org.jackhuang.hmcl.addon.repository.CurseForgeRemoteAddonRepository; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; -import org.jackhuang.hmcl.modpack.Modpack; -import org.jackhuang.hmcl.modpack.ModpackProvider; -import org.jackhuang.hmcl.modpack.ModpackUpdateTask; +import org.jackhuang.hmcl.modpack.*; import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; import org.jackhuang.hmcl.util.io.IOUtils; +import org.jetbrains.annotations.Nullable; +import java.io.FileNotFoundException; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; +import java.util.Set; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class CurseModpackProvider implements ModpackProvider { public static final CurseModpackProvider INSTANCE = new CurseModpackProvider(); @@ -50,11 +56,16 @@ public Task createCompletionTask(DefaultDependencyManager dependencyManager, } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + Path zipFile, + Modpack modpack, + @Nullable Set excludedFiles) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof CurseManifest curseManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(instance, new CurseInstallTask(dependencyManager, zipFile, modpack, curseManifest, instance, null)); + return new ModpackUpdateTask(instance, new CurseInstallTask(dependencyManager, zipFile, modpack, curseManifest, instance, null, excludedFiles)); } @Override @@ -70,10 +81,48 @@ public Modpack readManifest(ZipArchiveReader zip, Path file, Charset encoding) t return new Modpack(manifest.name(), manifest.author(), manifest.version(), manifest.minecraft().gameVersion(), description, encoding, manifest) { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) { - return new CurseInstallTask(dependencyManager, zipFile, this, manifest, instanceId, iconUrl); + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + GameInstanceID instanceId, + String iconUrl, + @Nullable Set excludedFiles) { + return new CurseInstallTask(dependencyManager, zipFile, this, manifest, instanceId, iconUrl, excludedFiles); } }; } + @Override + public CurseManifest loadFiles(DownloadProvider downloadProvider, ModpackManifest manifest1) { + if (!(manifest1 instanceof CurseManifest manifest)) + throw new IllegalArgumentException("manifest1 is not a CurseManifest"); + return manifest.setFiles( + manifest.files().parallelStream() + .map(file -> { + if (!file.optional()) { + return file; + } + try { + CurseManifestFile result = file; + if (StringUtils.isBlank(file.fileName()) || file.url() == null) { + RemoteAddon.File remoteFile = CurseForgeRemoteAddonRepository.MODS.getAddonFile( + Integer.toString(file.projectID()), Integer.toString(file.fileID())); + result = result.withFileName(remoteFile.filename()).withURL(remoteFile.url()); + } + if (!file.addonQueried()) { + RemoteAddon addon = CurseForgeRemoteAddonRepository.MODS.getAddonById( + downloadProvider, Integer.toString(file.projectID())); + result = result.withAddon(addon); + } + return result; + } catch (FileNotFoundException fof) { + LOG.warning("Could not query api.curseforge.com for deleted mods: " + file.projectID() + ", " + file.fileID(), fof); + return file; + } catch (IOException | JsonParseException e) { + LOG.warning("Unable to fetch the file name projectID=" + file.projectID() + ", fileID=" + file.fileID(), e); + return file; + } + }) + .toList()); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java index 24d1b5b968..5d4875c261 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackManifest.java @@ -38,6 +38,7 @@ import java.util.List; import java.util.Objects; import java.util.Optional; +import java.util.Set; public class McbbsModpackManifest implements ModpackManifest, Validation { public static final String MANIFEST_TYPE = "minecraftModpack"; @@ -424,7 +425,12 @@ public Modpack toModpack(Charset encoding) throws IOException { .orElseThrow(() -> new IOException("Cannot find game version")).getVersion(); return new Modpack(name, author, version, gameVersion, description, encoding, this) { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) { + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + GameInstanceID instanceId, + String iconUrl, + @Nullable Set excludedFiles) { return new McbbsModpackLocalInstallTask(dependencyManager, zipFile, this, McbbsModpackManifest.this, instanceId); } }; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java index e19eccbdcf..f08c2e3327 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/mcbbs/McbbsModpackProvider.java @@ -31,6 +31,9 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.nio.file.Path; +import java.util.Set; + +import org.jetbrains.annotations.Nullable; public final class McbbsModpackProvider implements ModpackProvider { public static final McbbsModpackProvider INSTANCE = new McbbsModpackProvider(); @@ -46,7 +49,12 @@ public Task createCompletionTask(DefaultDependencyManager dependencyManager, } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + Path zipFile, + Modpack modpack, + @Nullable Set excludedFiles) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof McbbsModpackManifest mcbbsModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java index bf712321f6..ec2dfca569 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthCompletionTask.java @@ -17,9 +17,9 @@ */ package org.jackhuang.hmcl.modpack.modrinth; +import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.download.DefaultDependencyManager; import org.jackhuang.hmcl.game.DefaultGameInstance; -import org.jackhuang.hmcl.addon.mod.ModManager; import org.jackhuang.hmcl.modpack.ModpackCompletionException; import org.jackhuang.hmcl.task.FileDownloadTask; import org.jackhuang.hmcl.task.Task; @@ -35,6 +35,8 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.Objects; +import java.util.Set; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; @@ -56,6 +58,9 @@ public class ModrinthCompletionTask extends Task { /// The manifest supplied by the caller or loaded from disk, if available. private @Nullable ModrinthManifest manifest; + /// Keys of optional files the user chose not to install; `null` means install all. + private @Nullable Set excludedFiles; + /// Download tasks produced during [#execute()]. private final List> dependencies = new ArrayList<>(); @@ -73,7 +78,7 @@ public class ModrinthCompletionTask extends Task { /// @param dependencyManager the dependency manager /// @param instance the registered instance to complete public ModrinthCompletionTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance) { - this(dependencyManager, instance, null); + this(dependencyManager, instance, null, null); } /// Creates a task that completes the installed Modrinth modpack using an optional manifest. @@ -81,21 +86,33 @@ public ModrinthCompletionTask(DefaultDependencyManager dependencyManager, Defaul /// @param dependencyManager the dependency manager /// @param instance the registered instance to complete /// @param manifest the Modrinth manifest, or `null` to read it from disk + /// @param excludedFiles keys of optional files the user chose not to install; `null` means install all. + /// When non-null, must not contain `null` elements. public ModrinthCompletionTask( DefaultDependencyManager dependencyManager, DefaultGameInstance instance, - @Nullable ModrinthManifest manifest) { + @Nullable ModrinthManifest manifest, + @Nullable Set excludedFiles) { dependencyManager.validateGameInstance(instance); this.dependency = dependencyManager; this.instance = instance; this.modManager = instance.getModManager(); this.manifest = manifest; + this.excludedFiles = excludedFiles == null ? null : Set.copyOf(excludedFiles); if (manifest == null) try { - Path manifestFile = instance.getInstanceRoot().resolve("modrinth.index.json"); + Path root = instance.getInstanceRoot(); + Path manifestFile = root.resolve("modrinth.index.json"); if (Files.exists(manifestFile)) this.manifest = JsonUtils.fromJsonFile(manifestFile, ModrinthManifest.class); + Path excludedFile = root.resolve("excluded.json"); + if (Files.exists(excludedFile)) { + this.excludedFiles = Set.copyOf(Objects.requireNonNull( + JsonUtils.fromJsonFile(excludedFile, JsonUtils.listTypeOf(String.class)))); + } else { + this.excludedFiles = null; + } } catch (Exception e) { LOG.warning("Unable to read Modrinth modpack manifest.json", e); } @@ -121,15 +138,23 @@ public void execute() throws Exception { Path runDirectory = FileUtils.toAbsolute(instance.getRunDirectory()); Path modsDirectory = runDirectory.resolve("mods"); + if (excludedFiles != null) { + JsonUtils.writeToJsonFile( + instance.getInstanceRoot().resolve("excluded.json"), + List.copyOf(excludedFiles)); + } + for (ModrinthManifest.File file : manifest.getFiles()) { - if (file.getEnv() != null && file.getEnv().getOrDefault("client", "required").equals("unsupported")) + if (file.env() != null && file.env().getOrDefault("client", "required").equals("unsupported")) + continue; + if (file.downloads().isEmpty()) continue; - if (file.getDownloads().isEmpty()) + if (excludedFiles != null && excludedFiles.contains(file.key())) continue; - Path filePath = runDirectory.resolve(file.getPath()).toAbsolutePath().normalize(); + Path filePath = runDirectory.resolve(file.path()).toAbsolutePath().normalize(); if (!filePath.startsWith(runDirectory)) - throw new IOException("Unsecure path: " + file.getPath()); + throw new IOException("Unsecure path: " + file.path()); if (Files.exists(filePath)) continue; @@ -137,7 +162,7 @@ public void execute() throws Exception { continue; var task = new FileDownloadTask( - dependency.getDownloadProvider().injectURLsWithCandidates(file.getDownloads()), + dependency.getDownloadProvider().injectURLsWithCandidates(file.downloads()), filePath); task.setCacheRepository(dependency.getCacheRepository()); task.setCaching(true); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java index dbcb979fee..7f8aa65c03 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthInstallTask.java @@ -57,6 +57,9 @@ public class ModrinthInstallTask extends Task { /// Previous modpack configuration when updating, or `null` for a new installation. private final @Nullable ModpackConfiguration config; + /// Keys of optional files the user chose not to install; `null` or empty means install all. + private final @Nullable Set excludedFiles; + /// Validated extension of the scheduled icon download, or `null` when no icon is scheduled. private @Nullable String iconExt; @@ -73,6 +76,8 @@ public class ModrinthInstallTask extends Task { /// @param manifest the Modrinth index /// @param instanceId the id of the new instance /// @param iconUrl the optional icon URL, or `null` + /// @param excludedFiles keys of optional files the user chose not to install; `null` or empty means + /// install all files. When non-null, must not contain `null` elements. /// @throws IllegalStateException if the manifest declares an unsupported mod loader, the target /// cannot be reserved, or another repository draft is open public ModrinthInstallTask( @@ -81,8 +86,9 @@ public ModrinthInstallTask( Modpack modpack, ModrinthManifest manifest, GameInstanceID instanceId, - @Nullable String iconUrl) { - this(dependencyManager, zipFile, modpack, manifest, instanceId, null, iconUrl); + @Nullable String iconUrl, + @Nullable Set excludedFiles) { + this(dependencyManager, zipFile, modpack, manifest, instanceId, null, iconUrl, excludedFiles); } /// Creates a task that updates an existing Modrinth modpack instance. @@ -93,6 +99,8 @@ public ModrinthInstallTask( /// @param manifest the Modrinth index /// @param instance the existing instance to update /// @param iconUrl the optional icon URL, or `null` + /// @param excludedFiles keys of optional files the user chose not to install; `null` or empty means + /// install all files. When non-null, must not contain `null` elements. /// @throws IllegalArgumentException if `instance` belongs to another repository, has no /// modpack configuration, or records another provider type /// @throws IllegalStateException if the manifest declares an unsupported mod loader, @@ -104,8 +112,9 @@ public ModrinthInstallTask( Modpack modpack, ModrinthManifest manifest, DefaultGameInstance instance, - @Nullable String iconUrl) { - this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance, iconUrl); + @Nullable String iconUrl, + @Nullable Set excludedFiles) { + this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance, iconUrl, excludedFiles); } /// Creates a Modrinth installation task in the mode selected by `updateTarget`. @@ -117,6 +126,8 @@ public ModrinthInstallTask( /// @param instanceId the target instance id /// @param updateTarget the existing instance selecting update mode, or `null` for install /// @param iconUrl the optional icon URL, or `null` + /// @param excludedFiles keys of optional files the user chose not to install; `null` or empty means + /// install all files. When non-null, must not contain `null` elements. /// @throws IllegalArgumentException if an update target has no compatible configuration /// @throws IllegalStateException if the manifest declares an unsupported mod loader, the /// target cannot be reserved, an update target is not the exact @@ -128,7 +139,8 @@ private ModrinthInstallTask( ModrinthManifest manifest, GameInstanceID instanceId, @Nullable DefaultGameInstance updateTarget, - @Nullable String iconUrl) { + @Nullable String iconUrl, + @Nullable Set excludedFiles) { this.dependencyManager = dependencyManager; this.zipFile = zipFile; this.modpack = modpack; @@ -136,6 +148,7 @@ private ModrinthInstallTask( this.instanceId = instanceId; this.updateTarget = updateTarget; this.iconUrl = iconUrl; + this.excludedFiles = excludedFiles == null ? null : Set.copyOf(excludedFiles); this.repository = dependencyManager.getGameRepository(); this.run = repository.getLayout().getInstanceRoot(instanceId); @@ -224,7 +237,7 @@ public void execute() throws Exception { if (config != null) { // For update, remove mods not listed in new manifest for (ModrinthManifest.File oldManifestFile : config.getManifest().getFiles()) { - Path oldFile = run.resolve(oldManifestFile.getPath()); + Path oldFile = run.resolve(oldManifestFile.path()); if (!Files.exists(oldFile)) continue; if (manifest.getFiles().stream().noneMatch(oldManifestFile::equals)) { Files.deleteIfExists(oldFile); @@ -249,6 +262,6 @@ public void execute() throws Exception { } // The game builder runs as a dependent and registers the instance before this phase. - dependencies.add(new ModrinthCompletionTask(dependencyManager, repository.getInstance(instanceId), manifest)); + dependencies.add(new ModrinthCompletionTask(dependencyManager, repository.getInstance(instanceId), manifest, excludedFiles)); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthManifest.java index 12577c7899..f0d71bdd44 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthManifest.java @@ -18,26 +18,58 @@ package org.jackhuang.hmcl.modpack.modrinth; import com.google.gson.JsonParseException; +import org.jackhuang.hmcl.addon.RemoteAddon; +import org.jackhuang.hmcl.modpack.ModpackFile; import org.jackhuang.hmcl.modpack.ModpackManifest; import org.jackhuang.hmcl.modpack.ModpackProvider; +import org.jackhuang.hmcl.util.DigestUtils; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonSerializable; import org.jackhuang.hmcl.util.gson.TolerableValidationException; import org.jackhuang.hmcl.util.gson.Validation; +import org.jetbrains.annotations.NotNullByDefault; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +import java.nio.file.Path; import java.util.List; import java.util.Map; import java.util.Objects; -public class ModrinthManifest implements ModpackManifest, Validation { +/// Modrinth modpack index (`modrinth.index.json`). +@NotNullByDefault +public class ModrinthManifest implements ModpackManifest, ModpackManifest.SupportOptional, Validation { + /// The game id, typically `minecraft`. private final String game; + + /// The Modrinth index format version. private final int formatVersion; + + /// The pack version id. private final String versionId; + + /// The pack display name. private final String name; + + /// The pack summary, or `null`. private final @Nullable String summary; + + /// Declared files in the pack. private final List files; + + /// Dependency versions such as `minecraft` and loaders. private final Map dependencies; + /// Creates a Modrinth index. + /// + /// @param game the game id + /// @param formatVersion the format version + /// @param versionId the pack version id + /// @param name the pack name + /// @param summary the summary, or `null` + /// @param files the files + /// @param dependencies the dependency map public ModrinthManifest(String game, int formatVersion, String versionId, String name, @Nullable String summary, List files, Map dependencies) { this.game = game; this.formatVersion = formatVersion; @@ -48,34 +80,64 @@ public ModrinthManifest(String game, int formatVersion, String versionId, String this.dependencies = dependencies; } + /// Returns the game id. + /// + /// @return the game id public String getGame() { return game; } + /// Returns the format version. + /// + /// @return the format version public int getFormatVersion() { return formatVersion; } + /// Returns the pack version id. + /// + /// @return the version id public String getVersionId() { return versionId; } + /// Returns the pack name. + /// + /// @return the name public String getName() { return name; } + /// Returns the summary, or an empty string when absent. + /// + /// @return the summary public String getSummary() { return summary == null ? "" : summary; } - public List getFiles() { + @Override + public @Unmodifiable List getFiles() { return files; } + /// Returns a copy with a different file list. + /// + /// @param files the new files + /// @return the updated manifest + public ModrinthManifest withFiles(List files) { + return new ModrinthManifest(game, formatVersion, versionId, name, summary, files, dependencies); + } + + /// Returns dependency versions such as Minecraft and loaders. + /// + /// @return the dependency map public Map getDependencies() { return dependencies; } + /// Returns the Minecraft version from dependencies. + /// + /// @return the Minecraft version public String getGameVersion() { return dependencies.get("minecraft"); } @@ -92,49 +154,75 @@ public void validate() throws JsonParseException, TolerableValidationException { } } - public static class File { - private final String path; - private final Map hashes; - @Nullable - private final Map env; - private final List downloads; - private final int fileSize; - + /// A file entry in a Modrinth index. + @JsonSerializable + @NotNullByDefault + public record File( + String path, + Map hashes, + @Nullable Map env, + List downloads, + int fileSize, + @Nullable RemoteAddon remoteAddon, + boolean addonQueried) implements Validation, ModpackFile { + + /// Creates a file entry that has not been queried for remote addon metadata. + /// + /// @param path the relative path + /// @param hashes the hashes + /// @param env the environment map, or `null` + /// @param downloads the download URLs + /// @param fileSize the file size public File(String path, Map hashes, @Nullable Map env, List downloads, int fileSize) { - this.path = path; - this.hashes = hashes; - this.env = env; - this.downloads = downloads; - this.fileSize = fileSize; + this(path, hashes, env, downloads, fileSize, null, false); } - public String getPath() { - return path; + @Override + public void validate() throws JsonParseException { + if (StringUtils.isBlank(path)) + throw new JsonParseException("Modrinth file path is missing."); + Path normalizedPath = Path.of(path).normalize(); + if (normalizedPath.isAbsolute() || normalizedPath.startsWith("..")) + throw new JsonParseException("Modrinth file path escapes the instance directory: " + path); + if (hashes == null || !DigestUtils.isSha512Digest(hashes.get("sha512"))) + throw new JsonParseException("Modrinth file sha512 is missing or invalid."); + if (env != null && !env.containsKey("client")) + throw new JsonParseException("Modrinth file env must contain a client key when present."); + if (downloads == null || downloads.isEmpty()) + throw new JsonParseException("Modrinth file downloads are missing."); } - public Map getHashes() { - return hashes; + @Override + public String key() { + return "modrinth:" + Objects.requireNonNull(hashes.get("sha512"), "sha512") + ":" + path; } - @Nullable - public Map getEnv() { - return env; + @Override + public String fileName() { + return Path.of(path).getFileName().toString(); } - public List getDownloads() { - return downloads; + /// Returns a copy marked as queried, with the given remote addon metadata. + /// + /// @param remoteAddon the remote addon, or `null` when not found + /// @return the updated file entry + public File withAddon(@Nullable RemoteAddon remoteAddon) { + return new File(path, hashes, env, downloads, fileSize, remoteAddon, true); } - public int getFileSize() { - return fileSize; + @Override + public boolean optional() { + return env != null && "optional".equals(env.get("client")); } @Override public boolean equals(Object o) { - if (this == o) return true; - if (o == null || getClass() != o.getClass()) return false; - File file = (File) o; - return fileSize == file.fileSize && path.equals(file.path) && hashes.equals(file.hashes) && Objects.equals(this.env, file.env) && downloads.equals(file.downloads); + return this == o || o instanceof File file + && fileSize == file.fileSize + && path.equals(file.path) + && hashes.equals(file.hashes) + && Objects.equals(env, file.env) + && downloads.equals(file.downloads); } @Override diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java index 219402aef3..8417f0fb69 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/modrinth/ModrinthModpackProvider.java @@ -19,20 +19,26 @@ import com.google.gson.JsonParseException; import kala.compress.archivers.zip.ZipArchiveReader; +import org.jackhuang.hmcl.addon.RemoteAddon; +import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository; import org.jackhuang.hmcl.download.DefaultDependencyManager; +import org.jackhuang.hmcl.download.DownloadProvider; import org.jackhuang.hmcl.game.DefaultGameInstance; import org.jackhuang.hmcl.game.GameInstanceID; -import org.jackhuang.hmcl.modpack.MismatchedModpackTypeException; -import org.jackhuang.hmcl.modpack.Modpack; -import org.jackhuang.hmcl.modpack.ModpackProvider; -import org.jackhuang.hmcl.modpack.ModpackUpdateTask; +import org.jackhuang.hmcl.modpack.*; import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jetbrains.annotations.Nullable; +import java.io.FileNotFoundException; import java.io.IOException; +import java.io.PrintStream; import java.nio.charset.Charset; import java.nio.file.Path; +import java.util.Set; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; public final class ModrinthModpackProvider implements ModpackProvider { public static final ModrinthModpackProvider INSTANCE = new ModrinthModpackProvider(); @@ -48,22 +54,69 @@ public Task createCompletionTask(DefaultDependencyManager dependencyManager, } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + Path zipFile, + Modpack modpack, + @Nullable Set excludedFiles) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof ModrinthManifest modrinthManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); - return new ModpackUpdateTask(instance, new ModrinthInstallTask(dependencyManager, zipFile, modpack, modrinthManifest, instance, null)); + return new ModpackUpdateTask(instance, new ModrinthInstallTask(dependencyManager, zipFile, modpack, modrinthManifest, instance, null, excludedFiles)); } @Override public Modpack readManifest(ZipArchiveReader zip, Path file, Charset encoding) throws IOException, JsonParseException { - ModrinthManifest manifest = JsonUtils.fromNonNullJson(CompressingUtils.readTextZipEntry(zip, "modrinth.index.json"), ModrinthManifest.class); - return new Modpack(manifest.getName(), "", manifest.getVersionId(), manifest.getGameVersion(), manifest.getSummary(), encoding, manifest) { - @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) { - return new ModrinthInstallTask(dependencyManager, zipFile, this, manifest, instanceId, iconUrl); + try { + ModrinthManifest manifest = JsonUtils.fromNonNullJson(CompressingUtils.readTextZipEntry(zip, "modrinth.index.json"), ModrinthManifest.class); + + return new Modpack(manifest.getName(), "", manifest.getVersionId(), manifest.getGameVersion(), manifest.getSummary(), encoding, manifest) { + @Override + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + GameInstanceID instanceId, + String iconUrl, + @Nullable Set excludedFiles) { + return new ModrinthInstallTask(dependencyManager, zipFile, this, manifest, instanceId, iconUrl, excludedFiles); + } + }; + } catch (IOException | JsonParseException ex) { + try (var os = new PrintStream("/dev/stdout")) { + ex.printStackTrace(os); } - }; + throw ex; + } } + @Override + public ModpackManifest loadFiles(DownloadProvider downloadProvider, ModpackManifest manifest1) { + if (!(manifest1 instanceof ModrinthManifest manifest)) + throw new IllegalArgumentException("Manifest is not a ModrinthManifest"); + return manifest.withFiles(manifest.getFiles().parallelStream().map(file -> { + if (file.optional() && !file.addonQueried()) { + try { + String sha1 = file.hashes().get("sha1"); + if (sha1 == null) { + return file.withAddon(null); + } + RemoteAddon.Version version = ModrinthRemoteAddonRepository.MODS.getRemoteVersionBySHA1(sha1).orElse(null); + if (version == null) { + return file.withAddon(null); + } + RemoteAddon addon = ModrinthRemoteAddonRepository.MODS.getAddonById(downloadProvider, version.projectId()); + return file.withAddon(addon); + } catch (FileNotFoundException fof) { + LOG.warning("Could not query modrinth for deleted mods: " + file.fileName(), fof); + return file; + } catch (IOException | JsonParseException e) { + LOG.warning("Unable to fetch the modid for " + file.fileName(), e); + return file; + } + } else { + return file; + } + }).toList()); + } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java index 9243dc7f33..d540b228b2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/multimc/MultiMCModpackProvider.java @@ -34,6 +34,7 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.nio.file.Path; +import java.util.Set; public final class MultiMCModpackProvider implements ModpackProvider { public static final MultiMCModpackProvider INSTANCE = new MultiMCModpackProvider(); @@ -49,7 +50,12 @@ public String getName() { } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + Path zipFile, + Modpack modpack, + @Nullable Set excludedFiles) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof MultiMCInstanceConfiguration multiMCInstanceConfiguration)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); @@ -88,7 +94,12 @@ public Modpack readManifest(ZipArchiveReader modpackFile, Path modpackPath, Char MultiMCInstanceConfiguration cfg = new MultiMCInstanceConfiguration(name, instanceStream, manifest); return new Modpack(cfg.getName(), "", "", cfg.getGameVersion(), cfg.getNotes(), encoding, cfg) { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) { + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + GameInstanceID instanceId, + String iconUrl, + @Nullable Set excludedFiles) { return new MultiMCModpackInstallTask(dependencyManager, zipFile, this, cfg, instanceId); } }; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java index 58dcc947b3..0278b227f2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackManifest.java @@ -28,12 +28,14 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.TolerableValidationException; import org.jackhuang.hmcl.util.gson.Validation; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; import java.util.Collections; import java.util.List; +import java.util.Set; public class ServerModpackManifest implements ModpackManifest, Validation { private final String name; @@ -126,7 +128,12 @@ public Modpack toModpack(Charset encoding) throws IOException { .orElseThrow(() -> new IOException("Cannot find game version")).getVersion(); return new Modpack(name, author, version, gameVersion, description, encoding, this) { @Override - public Task getInstallTask(DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, String iconUrl) { + public Task getInstallTask( + DefaultDependencyManager dependencyManager, + Path zipFile, + GameInstanceID instanceId, + String iconUrl, + @Nullable Set excludedFiles) { return new ServerModpackLocalInstallTask(dependencyManager, zipFile, this, ServerModpackManifest.this, instanceId); } }; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java index d081bcc889..51a92a6ff6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/modpack/server/ServerModpackProvider.java @@ -28,10 +28,12 @@ import org.jackhuang.hmcl.task.Task; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.nio.charset.Charset; import java.nio.file.Path; +import java.util.Set; public final class ServerModpackProvider implements ModpackProvider { public static final ServerModpackProvider INSTANCE = new ServerModpackProvider(); @@ -47,7 +49,12 @@ public Task createCompletionTask(DefaultDependencyManager dependencyManager, } @Override - public Task createUpdateTask(DefaultDependencyManager dependencyManager, DefaultGameInstance instance, Path zipFile, Modpack modpack) throws MismatchedModpackTypeException { + public Task createUpdateTask( + DefaultDependencyManager dependencyManager, + DefaultGameInstance instance, + Path zipFile, + Modpack modpack, + @Nullable Set excludedFiles) throws MismatchedModpackTypeException { if (!(modpack.getManifest() instanceof ServerModpackManifest serverModpackManifest)) throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName()); diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/DigestUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/DigestUtils.java index 4e61fd7dbd..9c1662109e 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/DigestUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/DigestUtils.java @@ -48,6 +48,23 @@ public static boolean isSha1Digest(String digest) { return true; } + /// Returns whether `digest` is a 512-bit hexadecimal SHA-512 digest (128 hex digits). + /// + /// @param digest the digest string to check, or `null` + /// @return `true` when `digest` is a valid SHA-512 hex digest + public static boolean isSha512Digest(String digest) { + if (digest == null || digest.length() != 128) return false; + + for (int i = 0; i < digest.length(); i++) { + char ch = digest.charAt(i); + if ((ch < '0' || ch > '9') && (ch < 'a' || ch > 'f') && (ch < 'A' || ch > 'F')) { + return false; + } + } + + return true; + } + public static MessageDigest getDigest(String algorithm) { try { return MessageDigest.getInstance(algorithm); diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java index 71856a047d..0a5efa439d 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/game/DefaultGameInstanceTest.java @@ -48,6 +48,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.zip.ZipEntry; import java.util.zip.ZipFile; import java.util.zip.ZipOutputStream; @@ -709,7 +710,8 @@ public Task getInstallTask( DefaultDependencyManager dependencyManager, Path zipFile, GameInstanceID instanceId, - String iconUrl) { + String iconUrl, + @Nullable Set excludedFiles) { throw new UnsupportedOperationException(); } };