Compare commits

..
68 changed files with 3967 additions and 1390 deletions

No files matched your search

@@ -33,6 +33,7 @@ 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 HMCLModpackProvider implements ModpackProvider {
public static final HMCLModpackProvider INSTANCE = new HMCLModpackProvider();
@@ -48,7 +49,12 @@ public final class HMCLModpackProvider implements ModpackProvider {
}
@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<String> excludedFiles) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof HMCLModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -77,7 +83,12 @@ public final class HMCLModpackProvider implements ModpackProvider {
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<String> excludedFiles) {
return new HMCLModpackInstallTask((HMCLGameRepository) dependencyManager.getGameRepository(), zipFile, this, instanceId);
}
}
@@ -56,6 +56,7 @@ import java.util.List;
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 final class ModpackHelper {
}
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<String> excludedFiles) {
ExceptionalRunnable<?> success = () -> {
repository.refresh();
repository.getInstance(instanceId).enableIsolation();
@@ -209,17 +230,17 @@ public final class ModpackHelper {
};
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 final class ModpackHelper {
}
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<String> 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());
}
@@ -21,12 +21,14 @@ import com.jfoenix.controls.JFXListView;
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<T> extends ListCell<T> {
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 @@ public abstract class MDListCell<T> extends ListCell<T> {
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
if (oldItem == item && oldEmpty == empty) {
updateLastPseudoClass(empty);
return;
}
ripplerContainer.releaseRippleImmediately();
@@ -68,6 +73,19 @@ public abstract class MDListCell<T> extends ListCell<T> {
} 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<T> listView = getListView();
boolean last = !empty
&& listView != null
&& getIndex() >= 0
&& getIndex() == listView.getItems().size() - 1;
root.pseudoClassStateChanged(LAST, last);
}
protected StackPane getContainer() {
@@ -80,5 +98,9 @@ public abstract class MDListCell<T> extends ListCell<T> {
});
}
protected void onClicked(Runnable action) {
FXUtils.onClicked(ripplerContainer, action);
}
protected abstract void updateControl(T item, boolean empty);
}
@@ -33,44 +33,16 @@ import javafx.scene.control.ListCell;
import javafx.scene.control.ProgressIndicator;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.StackPane;
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricInstallTask;
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
import org.jackhuang.hmcl.download.forge.ForgeOldInstallTask;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameInstallTask;
import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricInstallTask;
import org.jackhuang.hmcl.download.liteloader.LiteLoaderInstallTask;
import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask;
import org.jackhuang.hmcl.download.neoforge.NeoForgeOldInstallTask;
import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask;
import org.jackhuang.hmcl.download.quilt.QuiltAPIInstallTask;
import org.jackhuang.hmcl.download.quilt.QuiltInstallTask;
import org.jackhuang.hmcl.game.HMCLModpackInstallTask;
import org.jackhuang.hmcl.java.JavaInstallTask;
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask;
import org.jackhuang.hmcl.modpack.curse.CurseInstallTask;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthInstallTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackInstallTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackLocalInstallTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.task.TaskExecutor;
import org.jackhuang.hmcl.task.TaskListener;
import org.jackhuang.hmcl.ui.FXUtils;
import org.jackhuang.hmcl.ui.SVG;
import org.jackhuang.hmcl.util.FXThread;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.lang.ref.WeakReference;
import java.util.Collection;
@@ -161,58 +133,16 @@ public final class TaskListPane extends StackPane {
if (!task.getSignificance().shouldShow() || task.getName() == null)
return;
if (task instanceof GameAssetDownloadTask) {
task.setName(i18n("assets.download_all"));
} else if (task instanceof GameInstallTask) {
if (task.getInheritedStage() != null && task.getInheritedStage().startsWith("hmcl.install.game"))
return;
task.setName(i18n("install.installer.install", i18n("install.installer.game")));
} else if (task instanceof CleanroomInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.cleanroom")));
} else if (task instanceof LegacyFabricInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.legacyfabric")));
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.forge")));
} else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.neoforge")));
} else if (task instanceof LiteLoaderInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.liteloader")));
} else if (task instanceof OptiFineInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.optifine")));
} else if (task instanceof FabricInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.fabric")));
} else if (task instanceof FabricAPIInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.fabric-api")));
} else if (task instanceof QuiltInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.quilt")));
} else if (task instanceof QuiltAPIInstallTask) {
task.setName(i18n("install.installer.install", i18n("install.installer.quilt-api")));
} else if (task instanceof CurseCompletionTask || task instanceof ModrinthCompletionTask || task instanceof ServerModpackCompletionTask || task instanceof McbbsModpackCompletionTask) {
task.setName(i18n("modpack.completion"));
} else if (task instanceof ModpackInstallTask) {
task.setName(i18n("modpack.installing"));
} else if (task instanceof ModpackUpdateTask) {
task.setName(i18n("modpack.update"));
} else if (task instanceof CurseInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.curse")));
} else if (task instanceof MultiMCModpackInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.multimc")));
} else if (task instanceof ModrinthInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.modrinth")));
} else if (task instanceof ServerModpackLocalInstallTask) {
task.setName(i18n("install.installing") + ": " + i18n("modpack.type.server"));
} else if (task instanceof HMCLModpackInstallTask) {
task.setName(i18n("modpack.installing.given", i18n("modpack.type.hmcl")));
} else if (task instanceof McbbsModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
task.setName(i18n("modpack.export"));
} else if (task instanceof MinecraftInstanceTask) {
task.setName(i18n("modpack.scan"));
} else if (task instanceof MojangJavaDownloadTask) {
task.setName(i18n("download.java"));
} else if (task instanceof JavaInstallTask) {
task.setName(i18n("java.installing"));
if (task instanceof GameInstallTask
&& task.getInheritedStage() != null
&& task.getInheritedStage().startsWith("hmcl.install.game")) {
return;
}
@Nullable String localizedName = I18n.translateTaskName(task);
if (localizedName != null)
task.setName(localizedName);
Platform.runLater(() -> {
ProgressListNode node = new ProgressListNode(task);
nodes.put(task, node);
@@ -125,10 +125,12 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
setMessage(i18n("game_directory.already_exists"));
}
/// Rejects names used by other game directories, excluding the entry being edited.
@Override
protected void eval() {
JFXTextField control = (JFXTextField) this.getSrcControl();
hasErrors.set(GameDirectoryManager.getGameDirectories().stream()
.filter(existingGameDirectory -> existingGameDirectory != gameDirectory)
.anyMatch(existingGameDirectory -> Objects.equals(
GameDirectoryManager.getGameDirectoryCustomName(existingGameDirectory), control.getText())));
}
@@ -170,43 +172,52 @@ public final class GameDirectoryPage extends BorderPane implements DecoratorPage
StackPane.setAlignment(saveButton, Pos.BOTTOM_RIGHT);
saveButton.setPrefSize(100, 40);
saveButton.setOnAction(e -> onSave());
String initialLocation = getLocation();
boolean initialUseRelativePath = toggleUseRelativePath.isSelected();
saveButton.disableProperty().bind(Bindings.createBooleanBinding(
() -> !txtGameDirectoryName.validate() || StringUtils.isBlank(getLocation()),
txtGameDirectoryName.textProperty(), location));
() -> !txtGameDirectoryName.validate() || StringUtils.isBlank(getLocation())
|| (gameDirectory != null
&& Objects.equals(txtGameDirectoryName.getText(), gameDirectoryDisplayName)
&& Objects.equals(getLocation(), initialLocation)
&& toggleUseRelativePath.isSelected() == initialUseRelativePath),
txtGameDirectoryName.textProperty(), location, toggleUseRelativePath.selectedProperty()));
}
ChangeListener<String> locationChangeListener = (observable, oldValue, newValue) -> {
Path newPath;
try {
newPath = FileUtils.toAbsolute(Path.of(newValue));
} catch (InvalidPathException ignored) {
return;
}
if (!".minecraft".equals(FileUtils.getName(newPath)))
return;
Path parent = newPath.getParent();
if (parent == null)
return;
String suggestedName = FileUtils.getName(parent);
if (!suggestedName.isBlank()) {
txtGameDirectoryName.setText(suggestedName);
}
};
locationChangeListener.changed(null, this.location.get(), this.location.get());
locationProperty().addListener(locationChangeListener);
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
if (txtGameDirectoryName.isFocused()) {
txtGameDirectoryName.textProperty().removeListener(this);
locationProperty().removeListener(locationChangeListener);
if (gameDirectory == null) {
ChangeListener<String> locationChangeListener = (observable, oldValue, newValue) -> {
Path newPath;
try {
newPath = FileUtils.toAbsolute(Path.of(newValue));
} catch (InvalidPathException ignored) {
return;
}
}
});
if (!".minecraft".equals(FileUtils.getName(newPath)))
return;
@Nullable Path parent = newPath.getParent();
if (parent == null)
return;
String suggestedName = FileUtils.getName(parent);
if (!suggestedName.isBlank()) {
txtGameDirectoryName.setText(suggestedName);
}
};
locationChangeListener.changed(locationProperty(), this.location.get(), this.location.get());
locationProperty().addListener(locationChangeListener);
txtGameDirectoryName.textProperty().addListener(new ChangeListener<>() {
/// Stops automatic name suggestions after the user edits the name.
@Override
public void changed(ObservableValue<? extends String> observable, String oldValue, String newValue) {
if (txtGameDirectoryName.isFocused()) {
txtGameDirectoryName.textProperty().removeListener(this);
locationProperty().removeListener(locationChangeListener);
}
}
});
}
}
@FXThread
@@ -20,11 +20,18 @@ package org.jackhuang.hmcl.ui.download;
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.SettingsMap;
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<ModpackFile> allFiles = FXCollections.observableList(new ArrayList<>());
private final ObservableSet<String> 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 final class LocalModpackPage extends ModpackPage {
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 final class LocalModpackPage extends ModpackPage {
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 final class LocalModpackPage extends ModpackPage {
}
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<? extends ModpackFile> 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 @@ public final class LocalModpackPage extends ModpackPage {
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<String> 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 @@ public final class LocalModpackPage extends ModpackPage {
public static final SettingsMap.Key<Charset> MODPACK_CHARSET = new SettingsMap.Key<>("MODPACK_CHARSET");
public static final SettingsMap.Key<Boolean> MODPACK_MANUALLY_CREATED = new SettingsMap.Key<>("MODPACK_MANUALLY_CREATED");
public static final SettingsMap.Key<String> MODPACK_ICON_URL = new SettingsMap.Key<>("MODPACK_ICON_URL");
public static final SettingsMap.Key<Set<String>> MODPACK_EXCLUDED_FILES = new SettingsMap.Key<>("MODPACK_EXCLUDED_FILES");
}
@@ -90,6 +90,7 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
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 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
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 @@ public final class ModpackInstallWizardProvider implements WizardProvider {
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)));
}
}
@@ -22,6 +22,7 @@ import com.jfoenix.controls.JFXTextField;
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 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
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 @@ public abstract class ModpackPage extends SpinnerPane implements WizardPage {
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(
@@ -0,0 +1,322 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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<String> excludedFiles;
/// Per-file selected state mirrored from [#excludedFiles] (`true` means install / not excluded).
private final Map<String, BooleanProperty> selectedByKey = new HashMap<>();
/// Whether [#selectedByKey] is being updated from an [#excludedFiles] set change.
private boolean updatingFromSet;
/// List view showing optional files.
private final JFXListView<ModpackFile> 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<ModpackFile> optionalFiles,
ObservableSet<String> excludedFiles) {
this.excludedFiles = excludedFiles;
excludedFiles.addListener((SetChangeListener<String>) 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<ModpackFile> {
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<ModpackFile> 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) {
}
}
@@ -158,6 +158,10 @@ public class DownloadListPage extends Control implements DecoratorPage {
FXUtils.runInFX(() -> selectedInstance.set(instanceId));
}
public DownloadPage.DownloadCallback getCallback() {
return callback;
}
private void search(String userGameVersion, RemoteAddonRepository.Category category, int pageOffset, String searchFilter, RemoteAddonRepository.SortType sort) {
retrySearch = null;
setLoading(true);
@@ -207,6 +207,14 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
FXUtils.openFolder(gameInstance.getRunDirectory().resolve(sub));
}
private void onBrowseSchematics() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance == null) {
return;
}
FXUtils.openFolder(gameInstance.getSchematicsDirectory());
}
private void redownloadAssetIndex() {
HMCLGameInstance gameInstance = requireGameInstance();
if (gameInstance != null) {
@@ -358,7 +366,7 @@ public class GameInstancePage extends DecoratorAnimatedPage implements Decorator
new IconedMenuItem(SVG.EXTENSION, i18n("folder.mod"), () -> control.onBrowse("mods"), browsePopup),
new IconedMenuItem(SVG.TEXTURE, i18n("folder.resourcepacks"), () -> control.onBrowse("resourcepacks"), browsePopup),
new IconedMenuItem(SVG.PUBLIC, i18n("folder.saves"), () -> control.onBrowse("saves"), browsePopup),
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), () -> control.onBrowse("schematics"), browsePopup),
new IconedMenuItem(SVG.SCHEMA, i18n("folder.schematics"), control::onBrowseSchematics, browsePopup),
new IconedMenuItem(SVG.WB_SUNNY, i18n("folder.shaderpacks"), () -> control.onBrowse("shaderpacks"), browsePopup),
new IconedMenuItem(SVG.SCREENSHOT_MONITOR, i18n("folder.screenshots"), () -> control.onBrowse("screenshots"), browsePopup),
new IconedMenuItem(SVG.SETTINGS, i18n("folder.config"), () -> control.onBrowse("config"), browsePopup),
@@ -35,6 +35,10 @@ public final class HMCLLocalizedDownloadListPage extends DownloadListPage {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, CurseForgeRemoteAddonRepository.MODS, ModrinthRemoteAddonRepository.MODS);
}
public static DownloadListPage ofModrinthMod(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MOD, null, ModrinthRemoteAddonRepository.MODS);
}
public static DownloadListPage ofModPack(DownloadPage.DownloadCallback callback, boolean instanceSelection) {
return new HMCLLocalizedDownloadListPage(callback, instanceSelection, RemoteAddon.Type.MODPACK, CurseForgeRemoteAddonRepository.MODPACKS, ModrinthRemoteAddonRepository.MODPACKS);
}
@@ -95,7 +95,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
private ModManager modManager;
private @Nullable HMCLGameInstance gameInstance;
private String gameVersion;
final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
@@ -137,8 +136,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
return;
}
this.gameVersion = gameInstance.getVersion().toString();
loadMods(gameInstance.getModManager());
}
@@ -164,7 +161,8 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
return;
}
updateSupportedLoaders(modManager);
supportedLoaders.clear();
supportedLoaders.addAll(modManager.getSupportedLoaders());
if (exception == null) {
getItems().setAll(list);
@@ -176,51 +174,6 @@ public final class ModListPage extends ListPageBase<ModListPage.ModInfoObject> i
}, Schedulers.javafx());
}
private void updateSupportedLoaders(ModManager modManager) {
supportedLoaders.clear();
GameComponentAnalyzer analyzer = modManager.getComponentAnalyzer();
if (analyzer == null) {
Collections.addAll(supportedLoaders, ModLoaderType.values());
return;
}
for (GameComponentType type : GameComponentType.MOD_LOADERS) {
if (type.isModLoader() && analyzer.has(type)) {
ModLoaderType modLoaderType = type.getModLoaderType();
if (modLoaderType != null) {
supportedLoaders.add(modLoaderType);
if (modLoaderType == ModLoaderType.CLEANROOM)
supportedLoaders.add(ModLoaderType.FORGE);
}
}
}
if (analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
supportedLoaders.add(ModLoaderType.FORGE);
}
if (analyzer.has(GameComponentType.QUILT)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
if (analyzer.has(GameComponentType.LEGACY_FABRIC)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
if (analyzer.has(GameComponentType.FABRIC) && modManager.hasMod("kilt", ModLoaderType.FABRIC)) {
supportedLoaders.add(ModLoaderType.FORGE);
supportedLoaders.add(ModLoaderType.NEO_FORGE);
}
// Sinytra Connector
if (analyzer.has(GameComponentType.NEO_FORGE) && (modManager.hasMod("connector", ModLoaderType.NEO_FORGE) || modManager.hasMod("connectormod", ModLoaderType.NEO_FORGE))
|| "1.20.1".equals(gameVersion) && analyzer.has(GameComponentType.FORGE) && modManager.hasMod("connectormod", ModLoaderType.FORGE)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
}
public void add() {
FileChooser chooser = new FileChooser();
chooser.setTitle(i18n("mods.add.title"));
@@ -20,32 +20,48 @@ package org.jackhuang.hmcl.ui.instances;
import com.jfoenix.controls.JFXButton;
import com.jfoenix.controls.JFXDialogLayout;
import com.jfoenix.controls.JFXListView;
import javafx.beans.binding.Bindings;
import javafx.beans.property.BooleanProperty;
import javafx.beans.property.ObjectProperty;
import javafx.beans.property.SimpleBooleanProperty;
import javafx.beans.property.SimpleObjectProperty;
import javafx.beans.value.ObservableValue;
import javafx.geometry.Insets;
import javafx.geometry.Pos;
import javafx.scene.Node;
import javafx.scene.control.Label;
import javafx.scene.control.ListCell;
import javafx.scene.control.Skin;
import javafx.scene.control.Tooltip;
import javafx.scene.control.*;
import javafx.scene.image.Image;
import javafx.scene.image.PixelWriter;
import javafx.scene.image.WritableImage;
import javafx.scene.input.KeyCode;
import javafx.scene.input.KeyEvent;
import javafx.scene.layout.BorderPane;
import javafx.scene.layout.HBox;
import javafx.scene.layout.Priority;
import javafx.scene.layout.StackPane;
import javafx.stage.FileChooser;
import javafx.scene.text.Text;
import org.jackhuang.hmcl.addon.RemoteAddon;
import org.jackhuang.hmcl.addon.mod.LocalMod;
import org.jackhuang.hmcl.addon.mod.LocalModFile;
import org.jackhuang.hmcl.addon.repository.ModrinthRemoteAddonRepository;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.HMCLGameInstance;
import org.jackhuang.hmcl.schematic.LitematicFile;
import org.jackhuang.hmcl.schematic.Schematic;
import org.jackhuang.hmcl.schematic.SchematicType;
import org.jackhuang.hmcl.setting.DownloadProviders;
import org.jackhuang.hmcl.task.Schedulers;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.ui.*;
import org.jackhuang.hmcl.ui.construct.*;
import org.jackhuang.hmcl.util.FileNameSet;
import org.jackhuang.hmcl.util.Lang;
import org.jackhuang.hmcl.ui.nbt.NBTEditorPage;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.SynchronizedExceptionalLazy;
import org.jackhuang.hmcl.util.i18n.I18n;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -55,10 +71,12 @@ import java.nio.file.Files;
import java.nio.file.NoSuchFileException;
import java.nio.file.Path;
import java.util.*;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed;
import static org.jackhuang.hmcl.ui.FXUtils.*;
import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2;
import static org.jackhuang.hmcl.util.i18n.I18n.i18n;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
@@ -67,6 +85,23 @@ import static org.jackhuang.hmcl.util.logging.Logger.LOG;
*/
public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
private static final SynchronizedExceptionalLazy<RemoteAddon> litematicaLazy = new SynchronizedExceptionalLazy<>(() -> {
try {
return ModrinthRemoteAddonRepository.MODS.getAddonById(DownloadProviders.getDownloadProvider(), "litematica");
} catch (IOException e) {
LOG.warning("Failed to fetch litematica", e);
throw e;
}
});
private static final SynchronizedExceptionalLazy<RemoteAddon> forgematicaLazy = new SynchronizedExceptionalLazy<>(() -> {
try {
return ModrinthRemoteAddonRepository.MODS.getAddonById(DownloadProviders.getDownloadProvider(), "forgematica");
} catch (IOException e) {
LOG.warning("Failed to fetch forgematica", e);
throw e;
}
});
private static String translateAuthorName(String author) {
if (I18n.isUseChinese() && "hsds".equals(author)) {
return "黑山大叔";
@@ -74,9 +109,22 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
return author;
}
private static String translateType(SchematicType type) {
return switch (type) {
case SCHEM -> i18n("schematics.info.type.schem");
case NBT_STRUCTURE -> i18n("schematics.info.type.nbt_structure");
case LITEMATIC -> i18n("schematics.info.type.litematic");
};
}
private final WeakListenerHolder listenerHolder = new WeakListenerHolder();
private @Nullable HMCLGameInstance gameInstance;
private Path schematicsDirectory;
private DirItem currentDirectory;
private final ObjectProperty<DirItem> currentDirectory = new SimpleObjectProperty<>(this, "currentDirectory", null);
private final BooleanProperty isRootProperty = new SimpleBooleanProperty(this, "isRoot", true);
private final ObjectProperty<RemoteAddon> downloadTarget = new SimpleObjectProperty<>(this, "downloadTarget");
/// Creates a schematics list that reloads when `instanceContext` changes.
///
@@ -84,7 +132,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
public SchematicsPage(ObservableValue<? extends HMCLGameInstance.Optional> instanceContext) {
Objects.requireNonNull(instanceContext, "instanceContext");
FXUtils.applyDragListener(this,
file -> currentDirectory != null && Files.isRegularFile(file) && FileUtils.getName(file).endsWith(".litematic"),
file -> currentDirectoryProperty().get() != null && Schematic.isFileSchematic(file),
this::addFiles
);
@@ -97,53 +145,120 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
protected Skin<?> createDefaultSkin() {
return new SchematicsPageSkin();
return new SchematicsPageSkin(this);
}
public void loadInstance(HMCLGameInstance.Optional instance) {
HMCLGameInstance gameInstance = instance.instance();
this.schematicsDirectory = gameInstance != null ? gameInstance.getSchematicsDirectory() : null;
this.gameInstance = instance.instance();
if (gameInstance == null) {
this.schematicsDirectory = null;
return;
}
this.schematicsDirectory = gameInstance.getSchematicsDirectory();
refresh();
}
private ObjectProperty<DirItem> currentDirectoryProperty() {
return currentDirectory;
}
public BooleanProperty isRootProperty() {
return isRootProperty;
}
public void navigateBack() {
var d = currentDirectoryProperty().get();
if (d == null) return;
if (d.parent != null) navigateTo(d.parent);
}
public void downloadLitematica() {
if (downloadTarget.get() != null && gameInstance != null) {
var modDownloads = HMCLLocalizedDownloadListPage.ofModrinthMod(org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD, false);
modDownloads.loadInstance(HMCLGameInstance.Optional.of(gameInstance));
Controllers.navigate(new DownloadPage(
modDownloads,
downloadTarget.get(),
modDownloads.getInstanceOptional(),
modDownloads.getCallback())
);
}
}
public void refresh() {
Path schematicsDirectory = this.schematicsDirectory;
if (schematicsDirectory == null) return;
HMCLGameInstance currentGameInstance = this.gameInstance;
if (currentGameInstance == null) return;
setLoading(true);
Task.supplyAsync(() -> loadAll(schematicsDirectory, null))
.whenComplete(Schedulers.javafx(), (result, exception) -> {
setLoading(false);
if (exception == null) {
DirItem target = result;
if (currentDirectory != null) {
loop:
for (int i = 0; i < currentDirectory.relativePath.size(); i++) {
String dirName = currentDirectory.relativePath.get(i);
for (Item child : target.children) {
if (child instanceof DirItem && child.getName().equals(dirName)) {
target = (DirItem) child;
continue loop;
}
}
break;
}
}
navigateTo(target);
} else {
LOG.warning("Failed to load schematics", exception);
DirItem currentDir = currentDirectoryProperty().get();
Task.supplyAsync(Schedulers.io(), () -> {
DirItem target = loadRoot(schematicsDirectory);
if (currentDir != null) {
loop:
for (String dirName : currentDir.relativePath) {
try {
target.preLoad();
} catch (IOException e) {
LOG.warning("Failed to preload sub-directories in " + target, e);
break;
}
}).start();
for (var dirChild : target.dirChildren) {
if (dirChild.getName().equals(dirName)) {
target = dirChild;
continue loop;
}
}
break;
}
}
return target;
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (!Objects.equals(currentGameInstance, this.gameInstance)) return;
if (exception == null) {
navigateTo(result);
} else {
LOG.warning("Failed to load schematics", exception);
}
setLoading(false);
}).start();
downloadTarget.set(null);
Task.supplyAsync(Schedulers.io(), () -> {
var modManager = currentGameInstance.getModManager();
modManager.analyze();
var analyzer = modManager.getComponentAnalyzer();
if (analyzer == null) return null;
var modIds = modManager.getLocalFiles().stream()
.map(LocalModFile::getMod)
.map(LocalMod::getId)
.collect(Collectors.toSet());
if (modIds.contains("litematica") || modIds.contains("forgematica")) return null;
var gameVersionNumber = GameVersionNumber.asGameVersion(modManager.getGameVersion());
if (!gameVersionNumber.isAtLeast("1.12", "17w31a"))
return null;
if ((analyzer.has(GameComponentType.FORGE) || analyzer.has(GameComponentType.NEO_FORGE))
&& gameVersionNumber.isAtLeast("1.16.4", "20w45a"))
return forgematicaLazy.get();
return litematicaLazy.get();
}).whenComplete(Schedulers.javafx(), (result, exception) -> {
if (!Objects.equals(currentGameInstance, gameInstance)) return;
if (exception == null) {
downloadTarget.set(result);
} else {
LOG.warning("Failed to fetch litematica", exception);
}
}).start();
}
public void addFiles(List<Path> files) {
if (currentDirectory == null)
if (currentDirectoryProperty().get() == null)
return;
Path dir = currentDirectory.path;
Path dir = currentDirectoryProperty().get().getPath();
try {
// Can be executed in the background, but be careful that users can call loadVersion during this time
Files.createDirectories(dir);
@@ -162,7 +277,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
FileChooser fileChooser = new FileChooser();
fileChooser.setTitle(i18n("schematics.add.title"));
fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(
i18n("extension.schematic"), "*.litematic"));
i18n("extension.schematic"), "*.litematic", "*.nbt", "*.schematic", "*.schem"));
List<Path> files = Controllers.showOpenMultipleDialog(fileChooser);
if (files != null && !files.isEmpty()) {
addFiles(files);
@@ -170,10 +285,9 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
}
public void onCreateDirectory() {
if (currentDirectory == null)
return;
if (currentDirectoryProperty().get() == null) return;
Path parent = currentDirectory.path;
Path parent = currentDirectoryProperty().get().getPath();
FileNameSet existingPaths;
try {
@@ -208,37 +322,29 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
new Validator(i18n("schematics.create_directory.failed.already_exists"), existingPaths::notContains));
}
private DirItem loadAll(Path dir, @Nullable DirItem parent) {
DirItem item = new DirItem(dir, parent);
public void onRevealSchematicsFolder() {
var d = currentDirectoryProperty().get();
var p = d != null ? d.getPath() : schematicsDirectory;
if (p != null) FXUtils.openFolder(p);
}
try (Stream<Path> stream = Files.list(dir)) {
for (Path path : Lang.toIterable(stream)) {
if (Files.isDirectory(path)) {
item.children.add(loadAll(path, item));
} else if (path.getFileName().toString().endsWith(".litematic") && Files.isRegularFile(path)) {
try {
item.children.add(new LitematicFileItem(LitematicFile.load(path)));
} catch (IOException e) {
LOG.warning("Failed to load litematic file: " + path, e);
}
}
}
} catch (NoSuchFileException ignored) {
} catch (IOException e) {
LOG.warning("Failed to load schematics in " + dir, e);
}
item.children.sort(Comparator.naturalOrder());
private DirItem loadRoot(Path dir) {
var item = new DirItem(dir, null);
item.load();
return item;
}
private void navigateTo(DirItem item) {
currentDirectory = item;
getItems().clear();
if (item.parent != null) {
getItems().add(new BackItem(item.parent));
}
getItems().addAll(item.children);
if (currentDirectoryProperty().get() == item) return;
currentDirectoryProperty().set(item);
isRootProperty().set(item.parent == null);
setLoading(true);
Task.runAsync(item::load).whenComplete(Schedulers.javafx(), exception -> {
if (currentDirectoryProperty().get() == item) {
getItems().setAll(item.children);
setLoading(false);
}
}).start();
}
abstract sealed class Item implements Comparable<Item> {
@@ -280,12 +386,28 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
}
}
private final class BackItem extends Item {
private final class DirItem extends Item {
final ReentrantLock lock = new ReentrantLock();
private final DirItem parent;
final Path path;
final @Nullable DirItem parent;
final List<Item> children = new ArrayList<>();
final List<DirItem> dirChildren = new ArrayList<>();
final List<String> relativePath;
int size = 0;
boolean preLoaded = false;
boolean loaded = false;
BackItem(DirItem parent) {
DirItem(Path path, @Nullable DirItem parent) {
this.path = path;
this.parent = parent;
if (parent != null) {
this.relativePath = new ArrayList<>(parent.relativePath);
relativePath.add(FileUtils.getName(path));
} else {
this.relativePath = Collections.emptyList();
}
}
@Override
@@ -293,65 +415,6 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
return 0;
}
@Override
Path getPath() {
return null;
}
@Override
String getName() {
return "..";
}
@Override
String getDescription() {
return i18n("schematics.back_to", parent.getName());
}
@Override
SVG getIcon() {
return SVG.FOLDER;
}
@Override
void onClick() {
navigateTo(parent);
}
@Override
void onReveal() {
throw new UnsupportedOperationException("Unreachable");
}
@Override
void onDelete() {
throw new UnsupportedOperationException("Unreachable");
}
}
private final class DirItem extends Item {
final Path path;
final @Nullable DirItem parent;
final List<Item> children = new ArrayList<>();
final List<String> relativePath;
DirItem(Path path, @Nullable DirItem parent) {
this.path = path;
this.parent = parent;
if (parent != null) {
this.relativePath = new ArrayList<>(parent.relativePath);
relativePath.add(path.getFileName().toString());
} else {
this.relativePath = Collections.emptyList();
}
}
@Override
int order() {
return 1;
}
@Override
Path getPath() {
return path;
@@ -359,12 +422,12 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
public String getName() {
return path.getFileName().toString();
return FileUtils.getName(path);
}
@Override
String getDescription() {
return i18n("schematics.sub_items", children.size());
return i18n("schematics.sub_items", size);
}
@Override
@@ -372,6 +435,66 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
return SVG.FOLDER;
}
void preLoad() throws IOException {
lock.lock();
try {
if (this.preLoaded) return;
this.size = 0;
this.dirChildren.clear();
try (Stream<Path> stream = Files.list(path)) {
stream.forEach(p -> {
boolean b1 = Files.isDirectory(p);
boolean b2 = Schematic.isFileSchematic(p);
if (b1 || b2) this.size++;
if (b1) {
var child = new DirItem(p, this);
this.dirChildren.add(child);
}
});
}
this.preLoaded = true;
} finally {
lock.unlock();
}
}
void load() {
lock.lock();
try {
if (this.loaded) return;
this.children.clear();
preLoad();
for (var dir : dirChildren) {
try {
dir.preLoad();
} catch (IOException e) {
LOG.warning("Failed to pre-load schematics in " + dir.path, e);
}
this.children.add(dir);
}
try (Stream<Path> stream = Files.list(path)) {
stream.filter(Schematic::isFileSchematic)
.forEach(p -> {
try {
this.children.add(new SchematicItem(p));
} catch (IOException e) {
LOG.warning("Failed to load schematic file: " + p, e);
}
});
}
this.children.sort(Comparator.naturalOrder());
this.loaded = true;
} catch (NoSuchFileException ignored) {
} catch (IOException e) {
LOG.warning("Failed to load schematics in " + path, e);
} finally {
lock.unlock();
}
}
@Override
void onClick() {
navigateTo(this);
@@ -385,8 +508,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
void onDelete() {
try {
FileUtils.cleanDirectory(path);
Files.deleteIfExists(path);
FileUtils.deleteDirectory(path);
refresh();
} catch (IOException e) {
LOG.warning("Failed to delete directory: " + path, e);
@@ -394,19 +516,27 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
}
}
private final class LitematicFileItem extends Item {
final LitematicFile file;
private final class SchematicItem extends Item {
final Path path;
final Schematic file;
final String name;
final Image image;
private LitematicFileItem(LitematicFile file) {
this.file = file;
private SchematicItem(Path path) throws IOException {
this.path = path;
this.file = Schematic.load(path);
String name = file.getName();
if (name != null && !"Unnamed".equals(name)) {
this.name = name;
if (file == null) throw new IOException("Unsupported or deleted file: " + path);
if (this.file instanceof LitematicFile lFile) {
String name = lFile.getName();
if (StringUtils.isNotBlank(name) && !"Unnamed".equals(name)) {
this.name = name;
} else {
this.name = FileUtils.getNameWithoutExtension(path);
}
} else {
this.name = StringUtils.removeSuffix(file.getFile().getFileName().toString(), ".litematic");
this.name = FileUtils.getNameWithoutExtension(path);
}
WritableImage image = null;
@@ -430,12 +560,12 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
int order() {
return 2;
return 1;
}
@Override
Path getPath() {
return file.getFile();
return path;
}
@Override
@@ -445,7 +575,7 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
String getDescription() {
return file.getFile().getFileName().toString();
return path.getFileName().toString();
}
@Override
@@ -469,25 +599,25 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
@Override
void onClick() {
Controllers.dialog(new LitematicInfoDialog());
Controllers.dialog(new SchematicInfoDialog());
}
@Override
void onReveal() {
FXUtils.showFileInExplorer(file.getFile());
FXUtils.showFileInExplorer(path);
}
@Override
void onDelete() {
try {
Files.deleteIfExists(file.getFile());
Files.deleteIfExists(path);
refresh();
} catch (IOException e) {
LOG.warning("Failed to delete litematic file: " + file.getFile(), e);
LOG.warning("Failed to delete schematic file: " + path, e);
}
}
private final class LitematicInfoDialog extends JFXDialogLayout {
private final class SchematicInfoDialog extends JFXDialogLayout {
private final ComponentList details;
private void addDetailItem(String key, Object detail) {
@@ -497,38 +627,45 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
details.getContent().add(borderPane);
}
private void updateContent(LitematicFile file) {
private void updateContent(Schematic file) {
details.getContent().clear();
addDetailItem(i18n("schematics.info.name"), file.getName());
addDetailItem(i18n("schematics.info.type"), translateType(file.getType()));
if (StringUtils.isNotBlank(file.getAuthor()))
addDetailItem(i18n("schematics.info.schematic_author"), translateAuthorName(file.getAuthor()));
if (file.getTimeCreated() != null)
addDetailItem(i18n("schematics.info.time_created"), I18n.formatDateTime(file.getTimeCreated()));
if (file.getTimeModified() != null && !file.getTimeModified().equals(file.getTimeCreated()))
addDetailItem(i18n("schematics.info.time_modified"), I18n.formatDateTime(file.getTimeModified()));
if (file.getRegionCount() > 0)
addDetailItem(i18n("schematics.info.region_count"), String.valueOf(file.getRegionCount()));
if (file.getTotalVolume() > 0)
addDetailItem(i18n("schematics.info.total_volume"), file.getTotalVolume());
if (file.getTotalBlocks() > 0)
addDetailItem(i18n("schematics.info.total_blocks"), file.getTotalBlocks());
if (file.getRegionCount().isPresent())
addDetailItem(i18n("schematics.info.region_count"), String.valueOf(file.getRegionCount().getAsInt()));
if (file.getTotalVolume().isPresent())
addDetailItem(i18n("schematics.info.total_volume"), file.getTotalVolume().getAsLong());
if (file.getTotalBlocks().isPresent())
addDetailItem(i18n("schematics.info.total_blocks"), file.getTotalBlocks().getAsInt());
if (file.getEnclosingSize() != null)
addDetailItem(i18n("schematics.info.enclosing_size"),
String.format("%d x %d x %d", (int) file.getEnclosingSize().getX(),
(int) file.getEnclosingSize().getY(),
(int) file.getEnclosingSize().getZ()));
addDetailItem(i18n("schematics.info.version"), file.getVersion());
String.format("%d x %d x %d", file.getEnclosingSize().x(),
file.getEnclosingSize().y(),
file.getEnclosingSize().z()));
if (StringUtils.isNotBlank(file.getMinecraftVersion()))
addDetailItem(i18n("schematics.info.mc_data_version"), file.getMinecraftVersion());
if (file.getVersion().isPresent())
addDetailItem(i18n("schematics.info.version"),
file.getSubVersion().isPresent()
? "%d.%d".formatted(file.getVersion().getAsInt(), file.getSubVersion().getAsInt())
: file.getVersion().getAsInt()
);
}
LitematicInfoDialog() {
SchematicInfoDialog() {
HBox titleBox = new HBox(8);
{
Node icon = getIcon(40);
TwoLineListItem title = new TwoLineListItem();
title.setTitle(getName());
title.setSubtitle(file.getFile().getFileName().toString());
title.setSubtitle(path.getFileName().toString());
titleBox.getChildren().setAll(icon, title);
setHeading(titleBox);
@@ -536,10 +673,15 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
{
this.details = new ComponentList();
details.setStyle("-fx-effect: null;");
StackPane detailsContainer = new StackPane();
detailsContainer.setPadding(new Insets(10, 0, 0, 0));
detailsContainer.getChildren().add(details);
setBody(detailsContainer);
ScrollPane scrollPane = new ScrollPane(detailsContainer);
scrollPane.setFitToWidth(true);
FXUtils.setOverflowHidden(scrollPane, 8);
FXUtils.smoothScrolling(scrollPane);
StackPane.setMargin(scrollPane, new Insets(10, 0, 0, 0));
setBody(scrollPane);
}
{
@@ -552,15 +694,16 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
onEscPressed(this, okButton::fire);
}
this.prefWidthProperty().bind(Controllers.getDecorator().contentWidthProperty().multiply(0.6));
this.maxHeightProperty().bind(Controllers.getDecorator().contentHeightProperty().multiply(0.8));
updateContent(file);
}
}
}
private static final class Cell extends ListCell<Item> {
private static final class Cell extends MDListCell<Item> {
private final RipplerContainer graphics;
private final BorderPane root;
private final StackPane left;
private final TwoLineListItem center;
private final HBox right;
@@ -568,12 +711,17 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
private final ImageContainer iconImageView;
private final SVGContainer iconSVGView;
private final BooleanProperty isFileProperty = new SimpleBooleanProperty(this, "isFile", false);
private final BooleanProperty isDirectoryProperty = new SimpleBooleanProperty(this, "isDirectory", false);
private final Tooltip tooltip = new Tooltip();
public Cell() {
this.root = new BorderPane();
root.getStyleClass().add("md-list-cell");
root.setPadding(new Insets(8));
public Cell(JFXListView<Item> listView) {
super(listView);
var box = new HBox(8);
box.setAlignment(Pos.CENTER_LEFT);
box.setPickOnBounds(false);
{
this.left = new StackPane();
@@ -581,115 +729,186 @@ public final class SchematicsPage extends ListPageBase<SchematicsPage.Item> {
this.iconImageView = new ImageContainer(32);
this.iconSVGView = new SVGContainer(32);
BorderPane.setAlignment(left, Pos.CENTER);
root.setLeft(left);
}
{
this.center = new TwoLineListItem();
root.setCenter(center);
HBox.setHgrow(center, Priority.ALWAYS);
}
{
this.right = new HBox(8);
right.setAlignment(Pos.CENTER_RIGHT);
JFXButton btnReveal = FXUtils.newToggleButton4(SVG.FOLDER_OPEN);
FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager"));
{
var fo = SVG.FOLDER_OPEN.createIcon();
var f = SVG.FOLDER.createIcon();
btnReveal.graphicProperty().bind(isDirectoryProperty.map(b -> b ? fo : f));
var tooltip = new Tooltip();
tooltip.textProperty().bind(isDirectoryProperty.map(b -> b ? i18n("button.reveal_dir") : i18n("reveal.in_file_manager")));
FXUtils.installFastTooltip(btnReveal, tooltip);
}
btnReveal.setOnAction(event -> {
Item item = getItem();
if (item != null && !(item instanceof BackItem))
item.onReveal();
if (item != null) item.onReveal();
});
JFXButton btnExplore = FXUtils.newToggleButton4(SVG.EXPLORE); // Change the icon if allows editing
btnExplore.setOnAction(event -> {
Item item = getItem();
if (item instanceof SchematicItem) {
try {
Controllers.navigate(new NBTEditorPage(item.getPath()));
} catch (IOException ignored) { // Should be impossible
}
}
});
btnExplore.visibleProperty().bind(isFileProperty);
JFXButton btnDelete = FXUtils.newToggleButton4(SVG.DELETE_FOREVER);
btnDelete.setOnAction(event -> {
Item item = getItem();
if (item != null && !(item instanceof BackItem)) {
if (item != null) {
Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"),
item::onDelete, null);
}
});
right.getChildren().setAll(btnReveal, btnDelete);
right.getChildren().setAll(btnExplore, btnReveal, btnDelete);
}
this.graphics = new RipplerContainer(root);
FXUtils.onClicked(graphics, () -> {
Item item = getItem();
if (item != null)
item.onClick();
box.getChildren().setAll(left, center, right);
StackPane.setMargin(box, new Insets(8));
getContainer().getChildren().setAll(box);
onClicked(() -> {
var item = getItem();
if (item != null) item.onClick();
});
}
@Override
protected void updateItem(Item item, boolean empty) {
Item oldItem = getItem();
boolean oldEmpty = isEmpty();
protected void updateControl(Item item, boolean empty) {
if (empty || item == null) return;
super.updateItem(item, empty);
if (oldItem == item && oldEmpty == empty) return;
graphics.releaseRippleImmediately();
iconImageView.setImage(null);
if (empty || item == null) {
setGraphic(null);
center.setTitle("");
center.setSubtitle("");
isFileProperty.set(item instanceof SchematicItem);
isDirectoryProperty.set(item.isDirectory());
if (item instanceof SchematicItem fileItem && fileItem.getImage() != null) {
iconImageView.setImage(fileItem.getImage());
left.getChildren().setAll(iconImageView);
} else {
if (item instanceof LitematicFileItem fileItem && fileItem.getImage() != null) {
iconImageView.setImage(fileItem.getImage());
left.getChildren().setAll(iconImageView);
} else {
iconSVGView.setIcon(item.getIcon());
left.getChildren().setAll(iconSVGView);
}
iconSVGView.setIcon(item.getIcon());
left.getChildren().setAll(iconSVGView);
}
center.setTitle(item.getName());
center.setSubtitle(item.getDescription());
center.setTitle(item.getName());
center.setSubtitle(item.getDescription());
Path path = item.getPath();
if (path != null) {
tooltip.setText(FileUtils.getAbsolutePath(path));
FXUtils.installSlowTooltip(left, tooltip);
} else {
tooltip.setText("");
Tooltip.uninstall(left, tooltip);
}
root.setRight(item instanceof BackItem ? null : right);
setGraphic(graphics);
Path path = item.getPath();
if (path != null) {
tooltip.setText(FileUtils.getAbsolutePath(path));
FXUtils.installSlowTooltip(left, tooltip);
} else {
Tooltip.uninstall(left, tooltip);
}
}
}
private final class SchematicsPageSkin extends ToolbarListPageSkin<Item, SchematicsPage> {
SchematicsPageSkin() {
super(SchematicsPage.this);
private static final class SchematicsPageSkin extends SkinBase<SchematicsPage> {
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("schematics.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
private final JFXListView<Item> listView;
@Override
protected List<Node> initializeToolbar(SchematicsPage skinnable) {
return Arrays.asList(
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("schematics.add"), SVG.ADD, skinnable::onAddFiles),
createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory)
);
}
SchematicsPageSkin(SchematicsPage skinnable) {
super(skinnable);
@Override
protected ListCell<Item> createListCell(JFXListView<Item> listView) {
return new Cell();
StackPane pane = new StackPane();
pane.setPadding(new Insets(10));
pane.getStyleClass().addAll("notice-pane");
ComponentList root = new ComponentList();
root.getStyleClass().add("no-padding");
listView = new JFXListView<>();
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
{
StackPane placeholderContainer = new StackPane();
placeholderContainer.getStyleClass().add("notice-pane");
Label placeholderLabel = new Label(i18n("schematics.empty"));
placeholderContainer.getChildren().add(placeholderLabel);
listView.setPlaceholder(placeholderContainer);
}
{
var toolbar = new HBox();
JFXButton btnGoBack = createToolbarButton2("", SVG.ARROW_BACK, skinnable::navigateBack);
btnGoBack.disableProperty().bind(skinnable.isRootProperty());
JFXButton btnDownload = createToolbarButton2(i18n("schematics.install_mod"), SVG.DOWNLOAD, skinnable::downloadLitematica);
FXUtils.onChangeAndOperate(skinnable.downloadTarget, (t) -> btnDownload.setDisable(t == null));
toolbar.getChildren().setAll(
btnGoBack,
createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, skinnable::refresh),
createToolbarButton2(i18n("schematics.add"), SVG.ADD, skinnable::onAddFiles),
createToolbarButton2(i18n("schematics.create_directory"), SVG.CREATE_NEW_FOLDER, skinnable::onCreateDirectory),
createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, skinnable::onRevealSchematicsFolder),
btnDownload
);
FXUtils.setOverflowHidden(toolbar, 8);
root.getContent().add(toolbar);
}
{
SpinnerPane center = new SpinnerPane();
ComponentList.setVgrow(center, Priority.ALWAYS);
center.loadingProperty().bind(skinnable.loadingProperty());
listView.setCellFactory(x -> new Cell(listView));
listView.setSelectionModel(new NoneMultipleSelectionModel<>());
Bindings.bindContent(listView.getItems(), skinnable.getItems());
// ListViewBehavior would consume ESC pressed event, preventing us from handling it
// So we ignore it here
ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE);
listView.getStyleClass().add("no-horizontal-scrollbar");
center.setContent(listView);
root.getContent().add(center);
}
{
var relPath = new HBox();
relPath.getStyleClass().add("jfx-tool-bar-tip");
relPath.setAlignment(Pos.CENTER_LEFT);
FXUtils.onChangeAndOperate(skinnable.currentDirectoryProperty(), currentDir -> {
relPath.getChildren().clear();
var d = currentDir;
while (d != null) {
if (d != currentDir) {
var box = new HBox(new Text(">"));
box.setPadding(new Insets(3));
relPath.getChildren().add(0, box);
}
var txt = new Text(d.getName());
var box = new HBox(txt);
box.setPadding(new Insets(3));
box.setMouseTransparent(true);
var rippler = new RipplerContainer(box);
var finalD = d;
FXUtils.onClicked(rippler, () -> skinnable.navigateTo(finalD));
relPath.getChildren().add(0, rippler);
d = d.parent;
}
});
var relPathPane = new HBox(relPath);
relPathPane.setPadding(new Insets(2));
root.getContent().add(relPathPane);
}
pane.getChildren().setAll(root);
getChildren().setAll(pane);
}
}
}
@@ -29,7 +29,7 @@ import java.nio.file.Path;
/// @author Glavo
public enum NBTFileType {
COMPRESSED("nbt", "dat", "dat_old") {
COMPRESSED("dat", "dat_old", "litematic", "nbt", "schematic", "schem") {
@Override
public Tag read(Path file) throws IOException {
return NBTCodec.of().readTag(file);
@@ -0,0 +1,71 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
import org.jackhuang.hmcl.util.function.ExceptionalSupplier;
import org.jetbrains.annotations.Nullable;
import java.util.Objects;
import java.util.concurrent.locks.ReentrantLock;
/**
* Thread-safe exceptional lazy initialization wrapper.
* <p>
* On failure, the result is set to null in order to allow retrying.
*
* @param <T> value type
*/
public final class SynchronizedExceptionalLazy<T> {
private final ExceptionalSupplier<@Nullable T, Exception> supplier;
private volatile Result<T> result = null;
private final ReentrantLock lock = new ReentrantLock();
public SynchronizedExceptionalLazy(ExceptionalSupplier<@Nullable T, Exception> supplier) {
this.supplier = Objects.requireNonNull(supplier);
}
/// Gets and returns the result.
///
/// If the previous attempt succeeded, the cached value is returned directly.
///
/// Otherwise, the supplier is invoked to compute a new value.If this attempt fails,
/// {@code null} is returned; if succeeded, the computed value is cached and returned.
///
/// @return the result, or null on failure
public T get() {
if (result != null) return result.value();
lock.lock();
try {
if (result == null) {
try {
result = new Result<>(supplier.get());
} catch (Exception e) {
result = null;
return null;
}
}
return result.value();
} finally {
lock.unlock();
}
}
private record Result<T>(T value) {
}
}
@@ -21,7 +21,39 @@ import org.jackhuang.hmcl.addon.AddonLoader;
import org.jackhuang.hmcl.addon.AddonLoaderType;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.download.ComponentRemoteVersion;
import org.jackhuang.hmcl.download.cleanroom.CleanroomInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricAPIInstallTask;
import org.jackhuang.hmcl.download.fabric.FabricInstallTask;
import org.jackhuang.hmcl.download.forge.ForgeNewInstallTask;
import org.jackhuang.hmcl.download.forge.ForgeOldInstallTask;
import org.jackhuang.hmcl.download.game.GameAssetDownloadTask;
import org.jackhuang.hmcl.download.game.GameInstallTask;
import org.jackhuang.hmcl.download.game.GameRemoteVersion;
import org.jackhuang.hmcl.download.java.mojang.MojangJavaDownloadTask;
import org.jackhuang.hmcl.download.legacyfabric.LegacyFabricInstallTask;
import org.jackhuang.hmcl.download.liteloader.LiteLoaderInstallTask;
import org.jackhuang.hmcl.download.neoforge.NeoForgeInstallTask;
import org.jackhuang.hmcl.download.neoforge.NeoForgeOldInstallTask;
import org.jackhuang.hmcl.download.optifine.OptiFineInstallTask;
import org.jackhuang.hmcl.download.quilt.QuiltAPIInstallTask;
import org.jackhuang.hmcl.download.quilt.QuiltInstallTask;
import org.jackhuang.hmcl.game.HMCLModpackInstallTask;
import org.jackhuang.hmcl.java.JavaInstallTask;
import org.jackhuang.hmcl.modpack.MinecraftInstanceTask;
import org.jackhuang.hmcl.modpack.ModpackInstallTask;
import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
import org.jackhuang.hmcl.modpack.curse.CurseCompletionTask;
import org.jackhuang.hmcl.modpack.curse.CurseInstallTask;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackCompletionTask;
import org.jackhuang.hmcl.modpack.mcbbs.McbbsModpackExportTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthCompletionTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthInstallTask;
import org.jackhuang.hmcl.modpack.modrinth.ModrinthModpackExportTask;
import org.jackhuang.hmcl.modpack.multimc.MultiMCModpackInstallTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackCompletionTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackExportTask;
import org.jackhuang.hmcl.modpack.server.ServerModpackLocalInstallTask;
import org.jackhuang.hmcl.task.Task;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.i18n.translator.Translator;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
@@ -152,6 +184,60 @@ public final class I18n {
: StringUtils.capitalizeWords(loader.name());
}
public static @Nullable String translateTaskName(Task<?> task) {
if (task instanceof GameAssetDownloadTask) {
return i18n("assets.download_all");
} else if (task instanceof GameInstallTask) {
return i18n("install.installer.install", i18n("install.installer.game"));
} else if (task instanceof CleanroomInstallTask) {
return i18n("install.installer.install", i18n("install.installer.cleanroom"));
} else if (task instanceof LegacyFabricInstallTask) {
return i18n("install.installer.install", i18n("install.installer.legacyfabric"));
} else if (task instanceof ForgeNewInstallTask || task instanceof ForgeOldInstallTask) {
return i18n("install.installer.install", i18n("install.installer.forge"));
} else if (task instanceof NeoForgeInstallTask || task instanceof NeoForgeOldInstallTask) {
return i18n("install.installer.install", i18n("install.installer.neoforge"));
} else if (task instanceof LiteLoaderInstallTask) {
return i18n("install.installer.install", i18n("install.installer.liteloader"));
} else if (task instanceof OptiFineInstallTask) {
return i18n("install.installer.install", i18n("install.installer.optifine"));
} else if (task instanceof FabricInstallTask) {
return i18n("install.installer.install", i18n("install.installer.fabric"));
} else if (task instanceof FabricAPIInstallTask) {
return i18n("install.installer.install", i18n("install.installer.fabric-api"));
} else if (task instanceof QuiltInstallTask) {
return i18n("install.installer.install", i18n("install.installer.quilt"));
} else if (task instanceof QuiltAPIInstallTask) {
return i18n("install.installer.install", i18n("install.installer.quilt-api"));
} else if (task instanceof CurseCompletionTask || task instanceof ModrinthCompletionTask || task instanceof ServerModpackCompletionTask || task instanceof McbbsModpackCompletionTask) {
return i18n("modpack.completion");
} else if (task instanceof ModpackInstallTask) {
return i18n("modpack.installing");
} else if (task instanceof ModpackUpdateTask) {
return i18n("modpack.update");
} else if (task instanceof CurseInstallTask) {
return i18n("modpack.installing.given", i18n("modpack.type.curse"));
} else if (task instanceof MultiMCModpackInstallTask) {
return i18n("modpack.installing.given", i18n("modpack.type.multimc"));
} else if (task instanceof ModrinthInstallTask) {
return i18n("modpack.installing.given", i18n("modpack.type.modrinth"));
} else if (task instanceof ServerModpackLocalInstallTask) {
return i18n("install.installing") + ": " + i18n("modpack.type.server");
} else if (task instanceof HMCLModpackInstallTask) {
return i18n("modpack.installing.given", "HMCL");
} else if (task instanceof McbbsModpackExportTask || task instanceof ServerModpackExportTask || task instanceof ModrinthModpackExportTask) {
return i18n("modpack.export");
} else if (task instanceof MinecraftInstanceTask) {
return i18n("modpack.scan");
} else if (task instanceof MojangJavaDownloadTask) {
return i18n("download.java");
} else if (task instanceof JavaInstallTask) {
return i18n("java.installing");
}
return null;
}
public static boolean hasKey(String key) {
return getResourceBundle().containsKey(key);
}
@@ -713,6 +713,10 @@
-fx-text-fill: BLACK;
}
.jfx-tool-bar Text {
}
.jfx-tool-bar .jfx-options-burger {
-fx-padding: 22px;
}
@@ -761,6 +765,11 @@
-fx-padding: 10.0;
}
.jfx-tool-bar-tip Text {
-fx-font-size: 13;
-fx-fill: -monet-on-surface;
}
.jfx-decorator-button {
-fx-max-width: 40px;
-fx-background-radius: 5px;
@@ -1093,6 +1102,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;
}
@@ -1023,6 +1023,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
@@ -1330,7 +1333,6 @@ schematics=Schematics
schematics.add=Add
schematics.add.failed=Failed to add schematic files
schematics.add.title=Choose schematic file you want to import
schematics.back_to=Back to "%s"
schematics.create_directory=New Directory
schematics.create_directory.prompt=Please enter the new directory name
schematics.create_directory.failed=Failed to create directory
@@ -1346,7 +1348,13 @@ schematics.info.time_created=Created Time
schematics.info.time_modified=Modified Time
schematics.info.total_blocks=Total Blocks
schematics.info.total_volume=Total Volume
schematics.info.type=Schematic Type
schematics.info.type.litematic=Litematic (Litematica)
schematics.info.type.nbt_structure=NBT Structure (Vanilla, Create)
schematics.info.type.schem=Schem (Schematica, WE, MCEdit)
schematics.info.version=Schematic Version
schematics.info.mc_data_version=Minecraft Data Version
schematics.install_mod=Install Litematica Mod
schematics.manage=Schematics
schematics.sub_items=%d sub-item(s)
@@ -1024,6 +1024,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=الموقع الرسمي
@@ -1330,7 +1333,6 @@ schematics=المخططات
schematics.add=إضافة
schematics.add.failed=فشل إضافة ملفات المخططات
schematics.add.title=اختر ملف المخطط الذي تريد استيراده
schematics.back_to=العودة إلى "%s"
schematics.create_directory=مجلد جديد
schematics.create_directory.prompt=يُرجى إدخال اسم المجلد الجديد
schematics.create_directory.failed=فشل إنشاء المجلد
@@ -1346,7 +1348,13 @@ schematics.info.time_created=وقت الإنشاء
schematics.info.time_modified=وقت التعديل
schematics.info.total_blocks=إجمالي البلوكات
schematics.info.total_volume=الحجم الكلي
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=إصدار المخطط
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=المخططات
schematics.sub_items=%d عنصر فرعي
@@ -1023,6 +1023,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
@@ -1329,7 +1332,6 @@ schematics=Schematiken
schematics.add=Hinzufügen
schematics.add.failed=Fehler beim Hinzufügen der Schematik-Dateien
schematics.add.title=Wählen Sie die Schematik-Datei, die Sie importieren möchten
schematics.back_to=Zurück zu "%s"
schematics.create_directory=Neues Verzeichnis
schematics.create_directory.prompt=Bitte geben Sie den neuen Verzeichnisnamen ein
schematics.create_directory.failed=Fehler beim Erstellen des Verzeichnisses
@@ -1345,7 +1347,13 @@ schematics.info.time_created=Erstellungszeit
schematics.info.time_modified=Änderungszeit
schematics.info.total_blocks=Gesamte Blöcke
schematics.info.total_volume=Gesamtes Volumen
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=Schematik-Version
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=Schematiken
schematics.sub_items=%d Unterelement(e)
@@ -1016,6 +1016,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
@@ -1322,7 +1325,6 @@ schematics=Esquemas
# schematics.add=
schematics.add.failed=No se han podido añadir archivos de esquema
# schematics.add.title=
schematics.back_to=Atrás a «%s»
# schematics.create_directory=
schematics.create_directory.prompt=Introduzca el nuevo nombre del directorio
schematics.create_directory.failed=No se ha podido crear el directorio
@@ -1338,7 +1340,13 @@ schematics.info.time_created=Creado
schematics.info.time_modified=Modificado
schematics.info.total_blocks=Bloques totales
schematics.info.total_volume=Volumen total
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=Versión de esquema
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=Esquemas
schematics.sub_items=%d elemento(s)
@@ -826,6 +826,9 @@ modpack.installing=modpackのインストール
# modpack.installing.given=
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=公式ウェブサイト
@@ -1126,7 +1129,6 @@ reveal.in_file_manager=ファイルマネージャーで表示する
# schematics.add=
# schematics.add.failed=
# schematics.add.title=
# schematics.back_to=
# schematics.create_directory=
# schematics.create_directory.prompt=
# schematics.create_directory.failed=
@@ -1142,7 +1144,13 @@ reveal.in_file_manager=ファイルマネージャーで表示する
# schematics.info.time_modified=
# schematics.info.total_blocks=
# schematics.info.total_volume=
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
# schematics.info.version=
# schematics.info.mc_data_version=
# schematics.install_mod=
# schematics.manage=
# schematics.sub_items=
@@ -838,6 +838,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=官網
@@ -1138,7 +1141,6 @@ schematics=蜃圖
schematics.add=增蜃圖
schematics.add.failed=增蜃圖未成
# schematics.add.title=
schematics.back_to=退至「%s」
schematics.create_directory=增案夾
schematics.create_directory.prompt=書新案夾之名
schematics.create_directory.failed=增案夾未成
@@ -1154,7 +1156,13 @@ schematics.info.time_created=創之時辰
schematics.info.time_modified=改之時辰
schematics.info.total_blocks=總塊數
schematics.info.total_volume=總體積
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=蜃圖之版
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=司蜃圖
schematics.sub_items=%d 子项
File diff suppressed because it is too large. Load diff
@@ -978,6 +978,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=Офіційний сайт
@@ -1285,7 +1288,6 @@ schematics=Схематики
schematics.add=Додати
schematics.add.failed=Не вдалося додати файли схематик
schematics.add.title=Виберіть файл схеми, який ви хочете імпортувати
schematics.back_to=Назад до "%s"
schematics.create_directory=Новий каталог
schematics.create_directory.prompt=Введіть нову назву каталогу
schematics.create_directory.failed=Не вдалося створити каталог
@@ -1301,7 +1303,13 @@ schematics.info.time_created=Час створення
schematics.info.time_modified=Час зміни
schematics.info.total_blocks=Всього блоків
schematics.info.total_volume=Загальний об'єм
# schematics.info.type=
# schematics.info.type.litematic=
# schematics.info.type.nbt_structure=
# schematics.info.type.schem=
schematics.info.version=Версія схематики
# schematics.info.mc_data_version=
# schematics.install_mod=
schematics.manage=Схематики
schematics.sub_items=%d підпунктів
@@ -841,6 +841,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=官方网站
@@ -1142,7 +1145,6 @@ schematics=原理图
schematics.add=添加原理图
schematics.add.failed=添加原理图失败
schematics.add.title=选择要添加的原理图文件
schematics.back_to=返回到“%s”
schematics.create_directory=创建文件夹
schematics.create_directory.prompt=请输入新文件夹名称
schematics.create_directory.failed=创建文件夹失败
@@ -1158,7 +1160,13 @@ schematics.info.time_created=创建时间
schematics.info.time_modified=修改时间
schematics.info.total_blocks=总方块数
schematics.info.total_volume=总体积
schematics.info.type=原理图类型
schematics.info.type.litematic=Litematic (Litematica)
schematics.info.type.nbt_structure=NBT 结构 (原版, 机械动力)
schematics.info.type.schem=Schem (Schematica, WE, MCEdit)
schematics.info.version=原理图版本
schematics.info.mc_data_version=Minecraft 数据版本
schematics.install_mod=安装投影模组
schematics.manage=原理图管理
schematics.sub_items=%d 个子项
@@ -835,6 +835,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=官方網站
@@ -1136,7 +1139,6 @@ schematics=原理圖
schematics.add=新增原理圖
schematics.add.failed=新增原理圖失敗
schematics.add.title=選取要新增的原理圖檔案
schematics.back_to=返回到「%s」
schematics.create_directory=建立目錄
schematics.create_directory.prompt=請輸入新目錄名稱
schematics.create_directory.failed=建立目錄失敗
@@ -1152,7 +1154,13 @@ schematics.info.time_created=建立時間
schematics.info.time_modified=修改時間
schematics.info.total_blocks=總方塊數
schematics.info.total_volume=總體積
schematics.info.type=原理圖類型
schematics.info.type.litematic=Litematic (Litematica)
schematics.info.type.nbt_structure=NBT 結構 (原版, 機械動力)
schematics.info.type.schem=Schem (Schematica, WE, MCEdit)
schematics.info.version=原理圖版本
schematics.info.mc_data_version=Minecraft 資料版本
schematics.install_mod=安裝投影模組
schematics.manage=原理圖管理
schematics.sub_items=%d 個子項
+571
View File
@@ -1045,6 +1045,138 @@
}
}
},
"org.lwjgl:lwjgl:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-linux-arm64.jar",
"sha1": "101c753b129dbdeed565798d259b2b9544ef3878",
"size": 126343
}
}
},
"org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-linux-arm64.jar",
"sha1": "b558d38d8eaa32831bb66835aae163428fe69646",
"size": 235928
}
}
},
"org.lwjgl:lwjgl-openal:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-openal:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-linux-arm64.jar",
"sha1": "f2083556a7d8a02e4abeb2b933642250ef06c61d",
"size": 866036
}
}
},
"org.lwjgl:lwjgl-opengl:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-linux-arm64.jar",
"sha1": "84f229d793388be57ae5555b0cfee09b5990fa20",
"size": 80850
}
}
},
"org.lwjgl:lwjgl-glfw:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-glfw:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-linux-arm64.jar",
"sha1": "e2f84903e71f7f2c5fe11a0f6de9576da270c503",
"size": 144552
}
}
},
"org.lwjgl:lwjgl-stb:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-stb:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-linux-arm64.jar",
"sha1": "74f1cd22ccf4fd7432b982372b8a34c93cd9aacf",
"size": 258996
}
}
},
"org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-linux-arm64.jar",
"sha1": "97296e394d3d7dfebcc68754ab8273d44d8e9602",
"size": 45345
}
}
},
"org.lwjgl:lwjgl-freetype:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-linux-arm64.jar",
"sha1": "6babd5ddf9b4850e91a952d494cfd22b895ce200",
"size": 1300898
}
}
},
"org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-linux-arm64.jar",
"sha1": "f17eb9b12086809a31e61734dabc1dea80f6b7b2",
"size": 3540777
}
}
},
"org.lwjgl:lwjgl-spvc:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-linux-arm64.jar",
"sha1": "85d2636eb1368a3864fb10faec02a4269020b31e",
"size": 1166740
}
}
},
"org.lwjgl:lwjgl-vma:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-vma:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-linux-arm64.jar",
"sha1": "0a9e20e7d8ae4d0106cd23ae58a1b72ca151c49f",
"size": 60863
}
}
},
"org.lwjgl:lwjgl-sdl:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-linux-arm64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-linux-arm64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-linux-arm64.jar",
"sha1": "577e41b6d0006f3d3f841c14abe19736e695a760",
"size": 1460461
}
}
},
"org.lwjgl.lwjgl:lwjgl-platform:2.9.0:natives": {
"name": "org.glavo.hmcl:lwjgl2-natives:2.9.3",
"downloads": {
@@ -2956,6 +3088,181 @@
}
},
"org.lwjgl:lwjgl-sdl:3.4.2:natives-linux": null,
"org.lwjgl:lwjgl:3.4.3": {
"name": "org.lwjgl:lwjgl:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl/3.4.1/lwjgl-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl/3.4.1/lwjgl-3.4.1.jar",
"sha1": "6105690714874b995aa7812e4f7732a21109276c",
"size": 1157957
}
}
},
"org.lwjgl:lwjgl:3.4.3:natives-linux": {
"name": "org.glavo.hmcl:lwjgl3-natives:3.4.1-rc1",
"downloads": {
"classifiers": {
"linux-loongarch64": {
"path": "org/glavo/hmcl/lwjgl3-natives/3.4.1-rc1/lwjgl3-natives-3.4.1-rc1-linux-loongarch64.jar",
"url": "https://repo1.maven.org/maven2/org/glavo/hmcl/lwjgl3-natives/3.4.1-rc1-linux-loongarch64/lwjgl3-natives-3.4.1-rc1-linux-loongarch64.jar",
"sha1": "3fed872fc2f9929d2be25389dd312dfb48e9f89e",
"size": 22854580
}
}
},
"extract": {
"exclude": [
"META-INF/"
]
},
"natives": {
"linux": "linux-loongarch64"
}
},
"org.lwjgl:lwjgl-jemalloc:3.4.3": {
"name": "org.lwjgl:lwjgl-jemalloc:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-jemalloc/3.4.1/lwjgl-jemalloc-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-jemalloc/3.4.1/lwjgl-jemalloc-3.4.1.jar",
"sha1": "16b663092854fc6adfe0a941e61cae2bb2e437b6",
"size": 47872
}
}
},
"org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux": null,
"org.lwjgl:lwjgl-openal:3.4.3": {
"name": "org.lwjgl:lwjgl-openal:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-openal/3.4.1/lwjgl-openal-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-openal/3.4.1/lwjgl-openal-3.4.1.jar",
"sha1": "8508bd60589de0539aa465b78fcf838efd07ae9a",
"size": 153919
}
}
},
"org.lwjgl:lwjgl-openal:3.4.3:natives-linux": null,
"org.lwjgl:lwjgl-opengl:3.4.3": {
"name": "org.lwjgl:lwjgl-opengl:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-opengl/3.4.1/lwjgl-opengl-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-opengl/3.4.1/lwjgl-opengl-3.4.1.jar",
"sha1": "830412cab823c029cda6b9729f9d2ed36cf79959",
"size": 937660
}
}
},
"org.lwjgl:lwjgl-opengl:3.4.3:natives-linux": null,
"org.lwjgl:lwjgl-glfw:3.4.3": {
"name": "org.lwjgl:lwjgl-glfw:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-glfw/3.4.1/lwjgl-glfw-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-glfw/3.4.1/lwjgl-glfw-3.4.1.jar",
"sha1": "a782b1ddd175c9107bf300fd92920579ea65e2a4",
"size": 151893
}
}
},
"org.lwjgl:lwjgl-glfw:3.4.3:natives-linux": null,
"org.lwjgl:lwjgl-stb:3.4.3": {
"name": "org.lwjgl:lwjgl-stb:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-stb/3.4.1/lwjgl-stb-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-stb/3.4.1/lwjgl-stb-3.4.1.jar",
"sha1": "f077c3dafc31924fbe8acb0b913f08977ba27f8e",
"size": 142927
}
}
},
"org.lwjgl:lwjgl-stb:3.4.3:natives-linux": null,
"org.lwjgl:lwjgl-tinyfd:3.4.3": {
"name": "org.lwjgl:lwjgl-tinyfd:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-tinyfd/3.4.1/lwjgl-tinyfd-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-tinyfd/3.4.1/lwjgl-tinyfd-3.4.1.jar",
"sha1": "562697d40dbd2ed0424bf24dcf65d51170f81adf",
"size": 15931
}
}
},
"org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux": null,
"org.lwjgl:lwjgl-freetype:3.4.3": {
"name": "org.lwjgl:lwjgl-freetype:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-freetype/3.4.1/lwjgl-freetype-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-freetype/3.4.1/lwjgl-freetype-3.4.1.jar",
"sha1": "caa75a37e857efc9b0686e434d708d6420c5b6ea",
"size": 464342
}
}
},
"org.lwjgl:lwjgl-freetype:3.4.3:natives-linux": null,
"org.lwjgl:lwjgl-shaderc:3.4.3": {
"name": "org.lwjgl:lwjgl-shaderc:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-shaderc/3.4.1/lwjgl-shaderc-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-shaderc/3.4.1/lwjgl-shaderc-3.4.1.jar",
"sha1": "5c02c88f0d879529014c78463b76f43fdecfd272",
"size": 153051
}
}
},
"org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux": null,
"org.lwjgl:lwjgl-spvc:3.4.3": {
"name": "org.lwjgl:lwjgl-spvc:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-spvc/3.4.1/lwjgl-spvc-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-spvc/3.4.1/lwjgl-spvc-3.4.1.jar",
"sha1": "188553592e97b5ab2dba54a1b403ee80b48dad4c",
"size": 148285
}
}
},
"org.lwjgl:lwjgl-spvc:3.4.3:natives-linux": null,
"org.lwjgl:lwjgl-vma:3.4.3": {
"name": "org.lwjgl:lwjgl-vma:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-vma/3.4.1/lwjgl-vma-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-vma/3.4.1/lwjgl-vma-3.4.1.jar",
"sha1": "2c54ce10154feb1ede4d23c5947438cdbfdcc003",
"size": 109209
}
}
},
"org.lwjgl:lwjgl-vma:3.4.3:natives-linux": null,
"org.lwjgl:lwjgl-vulkan:3.4.3": {
"name": "org.lwjgl:lwjgl-vulkan:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-vulkan/3.4.1/lwjgl-vulkan-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-vulkan/3.4.1/lwjgl-vulkan-3.4.1.jar",
"sha1": "d0601fdc38890755e5638048c14cc0f7b990964b",
"size": 8356700
}
}
},
"org.lwjgl:lwjgl-sdl:3.4.3": {
"name": "org.lwjgl:lwjgl-sdl:3.4.1",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-sdl/3.4.1/lwjgl-sdl-3.4.1.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-sdl/3.4.1/lwjgl-sdl-3.4.1.jar",
"sha1": "ec28db6e949478044c649fea25eae43b1b161971",
"size": 983498
}
}
},
"org.lwjgl:lwjgl-sdl:3.4.3:natives-linux": null,
"net.java.dev.jna:jna:5.8.0": {
"name": "net.java.dev.jna:jna:5.13.0",
"downloads": {
@@ -4857,6 +5164,138 @@
}
}
},
"org.lwjgl:lwjgl:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-linux-riscv64.jar",
"sha1": "38374c04f498a9b214f7cb28cfd3fcc9737b8065",
"size": 127129
}
}
},
"org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-linux-riscv64.jar",
"sha1": "79b62b3efdcdba28d2f89e6a7d99d88cf3cda48f",
"size": 248363
}
}
},
"org.lwjgl:lwjgl-openal:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-openal:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-linux-riscv64.jar",
"sha1": "f4d3d1050f244b3a7fe9ddc02e94fbe0c507fc4d",
"size": 938685
}
}
},
"org.lwjgl:lwjgl-opengl:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-linux-riscv64.jar",
"sha1": "56ecca3a33c7966f3d678737757f5feb3efd03c2",
"size": 79430
}
}
},
"org.lwjgl:lwjgl-glfw:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-glfw:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-linux-riscv64.jar",
"sha1": "7f88d98db5c2fdd33063b5810df8a9809fd0983b",
"size": 141071
}
}
},
"org.lwjgl:lwjgl-stb:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-stb:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-linux-riscv64.jar",
"sha1": "8f0aeb06e1d532fd9411d08dcff6c0c3572d71a6",
"size": 268102
}
}
},
"org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-linux-riscv64.jar",
"sha1": "f9b6c895de7f43caf50c12b8d30fa3aef13e82ff",
"size": 56940
}
}
},
"org.lwjgl:lwjgl-freetype:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-linux-riscv64.jar",
"sha1": "809c6df123ae9ea36aa3117a368e7d300ed712eb",
"size": 1409111
}
}
},
"org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-linux-riscv64.jar",
"sha1": "1cb0fc92286da27d5782140a8ba72af5458eb1cf",
"size": 3702544
}
}
},
"org.lwjgl:lwjgl-spvc:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-linux-riscv64.jar",
"sha1": "38610faedc2a7c44f02c0bc7246ef1f8357a2846",
"size": 1221331
}
}
},
"org.lwjgl:lwjgl-vma:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-vma:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-linux-riscv64.jar",
"sha1": "e4dc73c846d42eb63c39c2b30d7cb5983a59bdbe",
"size": 66532
}
}
},
"org.lwjgl:lwjgl-sdl:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-linux-riscv64",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-linux-riscv64.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-linux-riscv64.jar",
"sha1": "30cd5ef9665905be2c0c7325dc848296d4788b49",
"size": 1496337
}
}
},
"com.github.oshi:oshi-core:6.6.5": {
"name": "com.github.oshi:oshi-core:6.8.0",
"downloads": {
@@ -7054,6 +7493,138 @@
}
}
},
"org.lwjgl:lwjgl:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl/3.4.3/lwjgl-3.4.3-natives-freebsd.jar",
"sha1": "fbab1de5df7db20be4c74bf6cf14750f599e6d79",
"size": 110742
}
}
},
"org.lwjgl:lwjgl-jemalloc:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-jemalloc:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-jemalloc/3.4.3/lwjgl-jemalloc-3.4.3-natives-freebsd.jar",
"sha1": "30a905bf255a0eac294da13cdfddb2963bda4541",
"size": 160297
}
}
},
"org.lwjgl:lwjgl-openal:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-openal:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-openal/3.4.3/lwjgl-openal-3.4.3-natives-freebsd.jar",
"sha1": "6f72cdd56726eacaaa1e4f5824f9fc88f0bc2272",
"size": 851111
}
}
},
"org.lwjgl:lwjgl-opengl:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-opengl:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-opengl/3.4.3/lwjgl-opengl-3.4.3-natives-freebsd.jar",
"sha1": "97992b72536b99ad922ac1318f397e049de50223",
"size": 83069
}
}
},
"org.lwjgl:lwjgl-glfw:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-glfw:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-glfw/3.4.3/lwjgl-glfw-3.4.3-natives-freebsd.jar",
"sha1": "783d56acc7b0bc5690f4d139d843d5cc083a7ef9",
"size": 108309
}
}
},
"org.lwjgl:lwjgl-stb:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-stb:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-stb/3.4.3/lwjgl-stb-3.4.3-natives-freebsd.jar",
"sha1": "0422952f19f24b9a10a29fcc44ac77ed8b842b6a",
"size": 225716
}
}
},
"org.lwjgl:lwjgl-tinyfd:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-tinyfd:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-tinyfd/3.4.3/lwjgl-tinyfd-3.4.3-natives-freebsd.jar",
"sha1": "5650a5b1cddce100f5c13761f7e4a5a6cf248320",
"size": 40582
}
}
},
"org.lwjgl:lwjgl-freetype:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-freetype:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-freetype/3.4.3/lwjgl-freetype-3.4.3-natives-freebsd.jar",
"sha1": "3938689ef674b5894b41af23de67aa53cf5c6a58",
"size": 1217752
}
}
},
"org.lwjgl:lwjgl-shaderc:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-shaderc:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-shaderc/3.4.3/lwjgl-shaderc-3.4.3-natives-freebsd.jar",
"sha1": "843c2c6b1588175e28eefe260d3833244119607b",
"size": 3063282
}
}
},
"org.lwjgl:lwjgl-spvc:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-spvc:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-spvc/3.4.3/lwjgl-spvc-3.4.3-natives-freebsd.jar",
"sha1": "f66777d0f5e784c203fda965e28e6f41f6287eed",
"size": 1122077
}
}
},
"org.lwjgl:lwjgl-vma:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-vma:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-vma/3.4.3/lwjgl-vma-3.4.3-natives-freebsd.jar",
"sha1": "b160d451c6224139bcb47d565dac76b1d242cf2b",
"size": 63336
}
}
},
"org.lwjgl:lwjgl-sdl:3.4.3:natives-linux": {
"name": "org.lwjgl:lwjgl-sdl:3.4.3:natives-freebsd",
"downloads": {
"artifact": {
"path": "org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-freebsd.jar",
"url": "https://repo1.maven.org/maven2/org/lwjgl/lwjgl-sdl/3.4.3/lwjgl-sdl-3.4.3-natives-freebsd.jar",
"sha1": "aad4d48eb5b3b72d550589fa6d07bbc6113b442e",
"size": 1242611
}
}
},
"net.java.jinput:jinput-platform:2.0.5:natives": null,
"com.mojang:text2speech:1.10.3:natives": null,
"com.mojang:text2speech:1.11.3:natives": null,
@@ -24,6 +24,7 @@ import org.jackhuang.hmcl.addon.meta.*;
import org.jackhuang.hmcl.game.DefaultGameInstance;
import org.jackhuang.hmcl.game.GameComponentAnalyzer;
import org.jackhuang.hmcl.game.GameComponentType;
import org.jackhuang.hmcl.game.NoSuchGameInstanceException;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.io.CompressingUtils;
@@ -65,7 +66,9 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
READERS = map;
}
private String gameVersion;
private final HashMap<Pair<String, ModLoaderType>, LocalMod> localMods = new HashMap<>();
private final EnumSet<ModLoaderType> supportedLoaders = EnumSet.noneOf(ModLoaderType.class);
private GameComponentAnalyzer analyzer;
private boolean loaded = false;
@@ -83,7 +86,12 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
}
public GameComponentAnalyzer getComponentAnalyzer() {
return analyzer;
lock.lock();
try {
return analyzer;
} finally {
lock.unlock();
}
}
public LocalMod getLocalMod(String modId, ModLoaderType modLoaderType) {
@@ -105,6 +113,24 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
}
}
public String getGameVersion() {
lock.lock();
try {
return gameVersion;
} finally {
lock.unlock();
}
}
public EnumSet<ModLoaderType> getSupportedLoaders() {
lock.lock();
try {
return EnumSet.copyOf(supportedLoaders);
} finally {
lock.unlock();
}
}
private void addModInfo(Path file) {
String fileName = StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION);
String extension = fileName.substring(fileName.lastIndexOf(".") + 1);
@@ -176,6 +202,18 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
}
}
public void analyze() throws IOException {
lock.lock();
try {
gameVersion = instance.getVersion().toString();
analyzer = instance.getAnalyzer();
} catch (NoSuchGameInstanceException e) {
throw new IOException(e);
} finally {
lock.unlock();
}
}
@Override
public void refresh() throws IOException {
lock.lock();
@@ -183,7 +221,7 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
localFiles.clear();
localMods.clear();
analyzer = instance.getAnalyzer();
analyze();
boolean supportSubfolders = analyzer.has(GameComponentType.FORGE)
|| analyzer.has(GameComponentType.QUILT)
@@ -205,6 +243,9 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
}
}
}
updateSupportedLoaders();
loaded = true;
} finally {
lock.unlock();
@@ -254,6 +295,50 @@ public final class ModManager extends LocalAddonManager<LocalModFile> {
}
}
private void updateSupportedLoaders() {
supportedLoaders.clear();
if (this.analyzer == null) {
Collections.addAll(supportedLoaders, ModLoaderType.values());
return;
}
for (GameComponentType type : GameComponentType.values()) {
if (type.isModLoader() && this.analyzer.has(type)) {
ModLoaderType modLoaderType = type.getModLoaderType();
if (modLoaderType != null) {
supportedLoaders.add(modLoaderType);
if (modLoaderType == ModLoaderType.CLEANROOM)
supportedLoaders.add(ModLoaderType.FORGE);
}
}
}
if (this.analyzer.has(GameComponentType.NEO_FORGE) && "1.20.1".equals(gameVersion)) {
supportedLoaders.add(ModLoaderType.FORGE);
}
if (this.analyzer.has(GameComponentType.QUILT)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
if (this.analyzer.has(GameComponentType.LEGACY_FABRIC)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
if (this.analyzer.has(GameComponentType.FABRIC) && hasMod("kilt", ModLoaderType.FABRIC)) {
supportedLoaders.add(ModLoaderType.FORGE);
supportedLoaders.add(ModLoaderType.NEO_FORGE);
}
// Sinytra Connector
if (this.analyzer.has(GameComponentType.NEO_FORGE) && (hasMod("connector", ModLoaderType.NEO_FORGE) || hasMod("connectormod", ModLoaderType.NEO_FORGE))
|| "1.20.1".equals(gameVersion) && this.analyzer.has(GameComponentType.FORGE) && hasMod("connectormod", ModLoaderType.FORGE)) {
supportedLoaders.add(ModLoaderType.FABRIC);
}
}
public void rollback(LocalModFile from, LocalModFile to) throws IOException {
lock.lock();
try {
@@ -213,8 +213,15 @@ public final class ModrinthRemoteAddonRepository implements RemoteAddonRepositor
@Override
public Optional<RemoteAddon.Version> 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<RemoteAddon.Version> getRemoteVersionBySHA1(String sha1) throws IOException {
SEMAPHORE.acquireUninterruptibly();
try {
ProjectVersion projectVersion = HttpRequest.GET(PREFIX + "/v2/version_file/" + sha1,
@@ -18,17 +18,23 @@
package org.jackhuang.hmcl.game;
import org.jackhuang.hmcl.addon.mod.ModLoaderType;
import org.jackhuang.hmcl.schematic.LitematicaConfig;
import org.jackhuang.hmcl.util.StringUtils;
import org.jackhuang.hmcl.util.gson.JsonUtils;
import org.jackhuang.hmcl.util.platform.Platform;
import org.jackhuang.hmcl.util.versioning.GameVersionNumber;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.EnumSet;
import java.util.Optional;
import java.util.Set;
import static org.jackhuang.hmcl.util.logging.Logger.LOG;
/// Provides a view of a game instance and its instance-specific paths within a
/// [GameRepositorySnapshot].
///
@@ -160,7 +166,25 @@ public interface GameInstance {
///
/// @return the schematics directory below the run directory
default Path getSchematicsDirectory() {
return getRunDirectory().resolve("schematics");
var runDir = getRunDirectory();
var litematicaConfig = runDir.resolve("config").resolve("litematica.json");
Path dir = runDir.resolve("schematics");
if (Files.isRegularFile(litematicaConfig)) {
try {
var conf = JsonUtils.fromJsonFile(litematicaConfig, LitematicaConfig.class);
if (conf != null
&& conf.generic() != null
&& conf.generic().customSchematicBaseDirectoryEnabled()
&& StringUtils.isNotBlank(conf.generic().customSchematicBaseDirectory())
) {
var p = Path.of(conf.generic().customSchematicBaseDirectory());
dir = p.isAbsolute() ? p.normalize() : runDir.resolve(p).normalize();
}
} catch (Exception e) {
LOG.warning("Failed to load custom schematics directory from litematica config at '%s'".formatted(litematicaConfig), e);
}
}
return dir;
}
/// Returns the directory used for extracted native libraries for a platform.
@@ -280,6 +280,7 @@ public class DefaultLauncher extends Launcher {
if (instance.hasComponent(GameComponentType.CLEANROOM)) {
libraryClasspath.removeIf(c -> c.contains("2.9.4-nightly-20150209"));
libraryClasspath.removeIf(c -> c.contains("platform-3.4.0"));
libraryClasspath.removeIf(c -> c.contains("icu4j-core-mojang"));
}
Path jar = instance.getInstanceJarFile();
@@ -59,7 +59,6 @@ public interface ModAdviser {
"irisUpdateInfo.json", // Iris
"modernfix", // ModernFix
"modtranslations", // Mod translations
"schematics", // Schematics mod
"journeymap/data", // JourneyMap
"mods/.connector" // Sinytra Connector
);
@@ -70,6 +69,7 @@ public interface ModAdviser {
"blueprints" /* BuildCraft */,
"optionsof.txt" /* OptiFine */,
"journeymap" /* JourneyMap */,
"schematics" /* Schematics mod */,
"optionsshaders.txt",
"mods/VoxelMods");
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.modpack;
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 abstract class Modpack {
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<String> excludedFiles);
public static boolean acceptFile(String path, List<String> blackList, List<String> whiteList) {
if (path.isEmpty())
@@ -0,0 +1,63 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
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();
}
@@ -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<? extends ModpackFile> getFiles();
}
}
@@ -20,6 +20,7 @@ package org.jackhuang.hmcl.modpack;
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 org.jetbrains.annotations.Nullable;
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<String> 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;
}
}
@@ -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.Files;
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<Void> {
/// 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<String> excludedFiles;
/// Download tasks produced during [#execute()].
private List<Task<?>> dependencies = List.of();
@@ -78,7 +82,7 @@ public final class CurseCompletionTask extends Task<Void> {
/// @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 final class CurseCompletionTask extends Task<Void> {
/// @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<String> 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);
}
@@ -151,8 +167,13 @@ public final class CurseCompletionTask extends Task<Void> {
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");
@@ -161,6 +182,7 @@ public final class CurseCompletionTask extends Task<Void> {
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);
@@ -171,7 +193,7 @@ public final class CurseCompletionTask extends Task<Void> {
var task = new FileDownloadTask(f.url(), path, f.getIntegrityCheck());
task.setCacheRepository(dependency.getCacheRepository());
task.setCaching(true);
return Stream.of(task.withCounter("hmcl.modpack.download"));
return Stream.<Task<?>>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.
@@ -179,7 +201,7 @@ public final class CurseCompletionTask extends Task<Void> {
updateProgress(finished.incrementAndGet(), newManifest.files().size());
}
})
.collect(Collectors.toList());
.toList();
if (!dependencies.isEmpty()) {
getProperties().put("total", dependencies.size());
@@ -61,6 +61,9 @@ public final class CurseInstallTask extends Task<Void> {
/// Previous modpack configuration when updating, or `null` for a new installation.
private final @Nullable ModpackConfiguration<CurseManifest> config;
/// Keys of optional files the user chose not to install; `null` or empty means install all.
private final @Nullable Set<String> 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<Void> {
/// @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 final class CurseInstallTask extends Task<Void> {
Modpack modpack,
CurseManifest manifest,
GameInstanceID instanceId,
@Nullable String iconUrl) {
this(dependencyManager, zipFile, modpack, manifest, instanceId, null, iconUrl);
@Nullable String iconUrl,
@Nullable Set<String> 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 final class CurseInstallTask extends Task<Void> {
/// @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 final class CurseInstallTask extends Task<Void> {
Modpack modpack,
CurseManifest manifest,
DefaultGameInstance instance,
@Nullable String iconUrl) {
this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance, iconUrl);
@Nullable String iconUrl,
@Nullable Set<String> 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 final class CurseInstallTask extends Task<Void> {
/// @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 @@ public final class CurseInstallTask extends Task<Void> {
CurseManifest manifest,
GameInstanceID instanceId,
@Nullable DefaultGameInstance updateTarget,
@Nullable String iconUrl) {
@Nullable String iconUrl,
@Nullable Set<String> excludedFiles) {
this.dependencyManager = dependencyManager;
this.zipFile = zipFile;
this.modpack = modpack;
@@ -138,6 +150,7 @@ public final class CurseInstallTask extends Task<Void> {
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 final class CurseInstallTask extends Task<Void> {
}
// 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));
}
}
@@ -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<CurseManifestFile> files) implements ModpackManifest {
@SerializedName("files") @Unmodifiable List<CurseManifestFile> files)
implements ModpackManifest, ModpackManifest.SupportOptional {
public CurseManifest setFiles(List<CurseManifestFile> files) {
return new CurseManifest(manifestType, manifestVersion, name, version, author, overrides, minecraft, files);
}
@Override
public List<? extends ModpackFile> getFiles() {
return files;
}
@Override
public ModpackProvider getProvider() {
return CurseModpackProvider.INSTANCE;
@@ -19,11 +19,15 @@ package org.jackhuang.hmcl.modpack.curse;
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.task.FileDownloadTask;
import org.jackhuang.hmcl.util.Pair;
import org.jackhuang.hmcl.util.gson.JsonSerializable;
import org.jackhuang.hmcl.util.gson.Validation;
import org.jetbrains.annotations.NotNullByDefault;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.List;
import java.util.Map;
@@ -31,31 +35,81 @@ import java.util.Objects;
import static org.jackhuang.hmcl.util.Pair.pair;
/// A CurseForge modpack file entry.
///
/// @param projectID the project id
/// @param fileID the file id
/// @param fileName the file name, or `null` when unresolved
/// @param url the explicit download URL, or `null` to derive it from the file name
/// @param required whether the file is required
/// @param hashes the hash algorithm names and checksums, or `null` when unavailable;
/// the map is retained without copying
/// @param remoteAddon the remote addon metadata, or `null` when unavailable
/// @param addonQueried whether remote addon metadata has been queried
/// @author huangyuhui
@NotNullByDefault
@JsonSerializable
public record CurseManifestFile(@SerializedName("projectID") int projectID,
@SerializedName("fileID") int fileID,
@SerializedName("fileName") String fileName,
@SerializedName("url") String url,
@SerializedName("required") boolean required,
@SerializedName("hashes") Map<String, String> hashes) 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,
@SerializedName("hashes") @Nullable Map<String, @Nullable String> hashes,
@Nullable RemoteAddon remoteAddon,
boolean addonQueried) implements Validation, ModpackFile {
private static final List<Pair<String, String>> HASH_ALGORITHMS = List.of(
/// Supported manifest hash names and digest algorithms, in selection order.
private static final @Unmodifiable List<Pair<String, String>> HASH_ALGORITHMS = List.of(
pair("sha1", "SHA-1"),
pair("sha256", "SHA-256"),
pair("sha512", "SHA-512"),
pair("md5", "MD5")
);
/// Creates a file entry without checksums or 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, null, false);
}
/// Returns `curseforge:<projectID>:<fileID>` as the exclusion key.
@Override
public String key() {
return "curseforge:" + projectID + ":" + fileID;
}
/// Returns whether this entry is not required by the manifest.
@Override
public boolean optional() {
return !required();
}
/// Returns the file name prefixed with `mods/`, or `null` when unresolved.
@Override
public @Nullable String path() {
return fileName != null ? "mods/" + fileName : null;
}
/// Checks that the project and file ids are nonzero.
///
/// @throws JsonParseException if either id is zero
@Override
public void validate() throws JsonParseException {
if (projectID == 0 || fileID == 0)
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)
@@ -65,12 +119,15 @@ public record CurseManifestFile(@SerializedName("projectID") int projectID,
}
}
@Nullable
public FileDownloadTask.IntegrityCheck getIntegrityCheck() {
/// Returns an integrity check using the first non-null checksum under the keys
/// `sha1`, `sha256`, `sha512`, and `md5`, in that order.
///
/// @return the integrity check, or `null` when no supported checksum is available
public @Nullable FileDownloadTask.IntegrityCheck getIntegrityCheck() {
if (hashes == null || hashes.isEmpty()) return null;
for (Pair<String, String> algorithm : HASH_ALGORITHMS) {
String hash = hashes.get(algorithm.key());
@Nullable String hash = hashes.get(algorithm.key());
if (hash != null) {
return new FileDownloadTask.IntegrityCheck(algorithm.value(), hash);
}
@@ -78,25 +135,48 @@ public record CurseManifestFile(@SerializedName("projectID") int projectID,
return null;
}
/// Returns a copy with a resolved file name, retaining all other fields.
///
/// @param fileName the resolved file name
/// @return the updated entry
public CurseManifestFile withFileName(String fileName) {
return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes);
return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes, remoteAddon, addonQueried);
}
/// Returns a copy with a resolved download URL, retaining all other fields.
///
/// @param url the download URL
/// @return the updated entry
public CurseManifestFile withURL(String url) {
return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes);
return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes, remoteAddon, addonQueried);
}
public CurseManifestFile withHashes(Map<String, String> hashes) {
return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes);
/// 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, retaining all other fields
public CurseManifestFile withAddon(@Nullable RemoteAddon remoteAddon) {
return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes, remoteAddon, true);
}
/// Returns a copy with the given checksums, retaining all other fields.
///
/// @param hashes the hash algorithm names and checksums, retained without copying;
/// `null` removes the checksums
/// @return the updated entry
public CurseManifestFile withHashes(@Nullable Map<String, @Nullable String> hashes) {
return new CurseManifestFile(projectID, fileID, fileName, url, required, hashes, remoteAddon, addonQueried);
}
/// Returns whether the other object is a file entry with the same project and file ids.
@Override
public boolean equals(Object o) {
public boolean equals(@Nullable Object o) {
return this == o || o instanceof CurseManifestFile that
&& this.projectID == that.projectID
&& this.fileID == that.fileID;
}
/// Returns a hash code based only on the project and file ids.
@Override
public int hashCode() {
return Objects.hash(projectID, fileID);
@@ -20,21 +20,27 @@ package org.jackhuang.hmcl.modpack.curse;
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 final class CurseModpackProvider implements ModpackProvider {
}
@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<String> 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 final class CurseModpackProvider implements ModpackProvider {
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<String> 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());
}
}
@@ -38,6 +38,7 @@ import java.util.Collections;
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 class McbbsModpackManifest implements ModpackManifest, Validation {
.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<String> excludedFiles) {
return new McbbsModpackLocalInstallTask(dependencyManager, zipFile, this, McbbsModpackManifest.this, instanceId);
}
};
@@ -31,6 +31,9 @@ import java.io.IOException;
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 final class McbbsModpackProvider implements ModpackProvider {
}
@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<String> excludedFiles) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof McbbsModpackManifest mcbbsModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -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.nio.file.Path;
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<Void> {
/// 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<String> excludedFiles;
/// Download tasks produced during [#execute()].
private final List<Task<?>> dependencies = new ArrayList<>();
@@ -73,7 +78,7 @@ public class ModrinthCompletionTask extends Task<Void> {
/// @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 class ModrinthCompletionTask extends Task<Void> {
/// @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<String> 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 class ModrinthCompletionTask extends Task<Void> {
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.getDownloads().isEmpty())
if (file.downloads().isEmpty())
continue;
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 class ModrinthCompletionTask extends Task<Void> {
continue;
var task = new FileDownloadTask(
dependency.getDownloadProvider().injectURLsWithCandidates(file.getDownloads()),
dependency.getDownloadProvider().injectURLsWithCandidates(file.downloads()),
filePath);
task.setCacheRepository(dependency.getCacheRepository());
task.setCaching(true);
@@ -57,6 +57,9 @@ public class ModrinthInstallTask extends Task<Void> {
/// Previous modpack configuration when updating, or `null` for a new installation.
private final @Nullable ModpackConfiguration<ModrinthManifest> config;
/// Keys of optional files the user chose not to install; `null` or empty means install all.
private final @Nullable Set<String> 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<Void> {
/// @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 class ModrinthInstallTask extends Task<Void> {
Modpack modpack,
ModrinthManifest manifest,
GameInstanceID instanceId,
@Nullable String iconUrl) {
this(dependencyManager, zipFile, modpack, manifest, instanceId, null, iconUrl);
@Nullable String iconUrl,
@Nullable Set<String> 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 class ModrinthInstallTask extends Task<Void> {
/// @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 class ModrinthInstallTask extends Task<Void> {
Modpack modpack,
ModrinthManifest manifest,
DefaultGameInstance instance,
@Nullable String iconUrl) {
this(dependencyManager, zipFile, modpack, manifest, instance.getId(), instance, iconUrl);
@Nullable String iconUrl,
@Nullable Set<String> 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 class ModrinthInstallTask extends Task<Void> {
/// @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 @@ public class ModrinthInstallTask extends Task<Void> {
ModrinthManifest manifest,
GameInstanceID instanceId,
@Nullable DefaultGameInstance updateTarget,
@Nullable String iconUrl) {
@Nullable String iconUrl,
@Nullable Set<String> excludedFiles) {
this.dependencyManager = dependencyManager;
this.zipFile = zipFile;
this.modpack = modpack;
@@ -136,6 +148,7 @@ public class ModrinthInstallTask extends Task<Void> {
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 class ModrinthInstallTask extends Task<Void> {
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 class ModrinthInstallTask extends Task<Void> {
}
// 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));
}
}
@@ -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<File> files;
/// Dependency versions such as `minecraft` and loaders.
private final Map<String, String> 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<File> files, Map<String, String> dependencies) {
this.game = game;
this.formatVersion = formatVersion;
@@ -48,34 +80,64 @@ public class ModrinthManifest implements ModpackManifest, Validation {
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<File> getFiles() {
@Override
public @Unmodifiable List<File> getFiles() {
return files;
}
/// Returns a copy with a different file list.
///
/// @param files the new files
/// @return the updated manifest
public ModrinthManifest withFiles(List<File> 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<String, String> 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 class ModrinthManifest implements ModpackManifest, Validation {
}
}
public static class File {
private final String path;
private final Map<String, String> hashes;
@Nullable
private final Map<String, String> env;
private final List<String> downloads;
private final int fileSize;
/// A file entry in a Modrinth index.
@JsonSerializable
@NotNullByDefault
public record File(
String path,
Map<String, String> hashes,
@Nullable Map<String, String> env,
List<String> 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<String, String> hashes, @Nullable Map<String, String> env, List<String> 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<String, String> getHashes() {
return hashes;
@Override
public String key() {
return "modrinth:" + Objects.requireNonNull(hashes.get("sha512"), "sha512") + ":" + path;
}
@Nullable
public Map<String, String> getEnv() {
return env;
@Override
public String fileName() {
return Path.of(path).getFileName().toString();
}
public List<String> 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
@@ -19,20 +19,26 @@ package org.jackhuang.hmcl.modpack.modrinth;
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 final class ModrinthModpackProvider implements ModpackProvider {
}
@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<String> 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<String> 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());
}
}
@@ -34,6 +34,7 @@ import java.io.IOException;
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 final class MultiMCModpackProvider implements ModpackProvider {
}
@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<String> excludedFiles) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof MultiMCInstanceConfiguration multiMCInstanceConfiguration))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -88,7 +94,12 @@ public final class MultiMCModpackProvider implements ModpackProvider {
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<String> excludedFiles) {
return new MultiMCModpackInstallTask(dependencyManager, zipFile, this, cfg, instanceId);
}
};
@@ -28,12 +28,14 @@ import org.jackhuang.hmcl.modpack.ModpackProvider;
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 class ServerModpackManifest implements ModpackManifest, Validation {
.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<String> excludedFiles) {
return new ServerModpackLocalInstallTask(dependencyManager, zipFile, this, ServerModpackManifest.this, instanceId);
}
};
@@ -28,10 +28,12 @@ import org.jackhuang.hmcl.modpack.ModpackUpdateTask;
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 final class ServerModpackProvider implements ModpackProvider {
}
@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<String> excludedFiles) throws MismatchedModpackTypeException {
if (!(modpack.getManifest() instanceof ServerModpackManifest serverModpackManifest))
throw new MismatchedModpackTypeException(getName(), modpack.getManifest().getProvider().getName());
@@ -17,33 +17,31 @@
*/
package org.jackhuang.hmcl.schematic;
import javafx.geometry.Point3D;
import org.glavo.nbt.io.NBTCodec;
import org.glavo.nbt.tag.*;
import org.glavo.nbt.tag.CompoundTag;
import org.glavo.nbt.tag.IntArrayTag;
import org.glavo.nbt.tag.IntTag;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.util.Point3I;
import org.jackhuang.hmcl.util.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.zip.GZIPInputStream;
import java.util.OptionalInt;
import java.util.OptionalLong;
/// @author Glavo
/// @see <a href="https://litemapy.readthedocs.io/en/v0.9.0b0/litematics.html">The Litematic file format</a>
public final class LitematicFile {
import static org.jackhuang.hmcl.util.NBTUtils.*;
private static @Nullable String tryGetString(Tag tag) {
return tag instanceof StringTag stringTag ? stringTag.get() : null;
}
/**
* @author Glavo
* @see <a href="https://litemapy.readthedocs.io/en/v0.9.0b0/litematics.html">The Litematic file format</a>
*/
public final class LitematicFile extends Schematic {
public static LitematicFile load(Path file) throws IOException {
CompoundTag root;
try (InputStream in = new GZIPInputStream(Files.newInputStream(file))) {
root = NBTCodec.of().readTag(in, TagType.COMPOUND);
}
CompoundTag root = readCompressed(file);
Tag versionTag = root.get("Version");
if (versionTag == null)
@@ -61,19 +59,29 @@ public final class LitematicFile {
if (root.get("Regions") instanceof CompoundTag regionsTag)
regions = regionsTag.size();
Point3I enclosingSize = null;
Tag enclosingSizeTag = ((CompoundTag) metadataTag).get("EnclosingSize");
if (enclosingSizeTag instanceof CompoundTag) {
CompoundTag list = (CompoundTag) enclosingSizeTag;
int x = tryGetInt(list.get("x")).orElse(0);
int y = tryGetInt(list.get("y")).orElse(0);
int z = tryGetInt(list.get("z")).orElse(0);
if (x > 0 && y > 0 && z > 0) enclosingSize = new Point3I(x, y, z);
}
Tag subVersionTag = root.get("SubVersion");
return new LitematicFile(file, (CompoundTag) metadataTag,
((IntTag) versionTag).getValue(),
root.getIntOrZero("SubVersion"),
root.getIntOrZero("MinecraftDataVersion"),
regions
((IntTag) versionTag).get(),
tryGetInt(subVersionTag).orElse(-1),
tryGetInt(root.get("MinecraftDataVersion")).orElse(0),
regions,
enclosingSize
);
}
private final @NotNull Path file;
private final int version;
private final int subVersion;
private final int minecraftDataVersion;
private final int regionCount;
private final int[] previewImageData;
private final String name;
@@ -83,14 +91,12 @@ public final class LitematicFile {
private final Instant timeModified;
private final int totalBlocks;
private final int totalVolume;
private final Point3D enclosingSize;
private LitematicFile(@NotNull Path file, @NotNull CompoundTag metadata,
int version, int subVersion, int minecraftDataVersion, int regionCount) {
this.file = file;
int version, int subVersion, int minecraftDataVersion, int regionCount, Point3I enclosingSize) {
super(file, minecraftDataVersion, enclosingSize);
this.version = version;
this.subVersion = subVersion;
this.minecraftDataVersion = minecraftDataVersion;
this.regionCount = regionCount;
Tag previewImageData = metadata.get("PreviewImageData");
@@ -101,48 +107,39 @@ public final class LitematicFile {
this.name = tryGetString(metadata.get("Name"));
this.author = tryGetString(metadata.get("Author"));
this.description = tryGetString(metadata.get("Description"));
this.timeCreated = metadata.get("TimeCreated") instanceof LongTag time ? Instant.ofEpochMilli(time.getValue()) : null;
this.timeModified = metadata.get("TimeModified") instanceof LongTag time ? Instant.ofEpochMilli(time.getValue()) : null;
this.totalBlocks = metadata.getIntOrZero("TotalBlocks");
this.totalVolume = metadata.getIntOrZero("TotalVolume");
Point3D enclosingSize = null;
if (metadata.get("EnclosingSize") instanceof CompoundTag list) {
int x = list.getIntOrZero("x");
int y = list.getIntOrZero("y");
int z = list.getIntOrZero("z");
if (x >= 0 && y >= 0 && z >= 0)
enclosingSize = new Point3D(x, y, z);
}
this.enclosingSize = enclosingSize;
this.timeCreated = tryGetLongTimestamp(metadata.get("TimeCreated"));
this.timeModified = tryGetLongTimestamp(metadata.get("TimeModified"));
this.totalBlocks = tryGetInt(metadata.get("TotalBlocks")).orElse(-1);
this.totalVolume = tryGetInt(metadata.get("TotalVolume")).orElse(-1);
}
public @NotNull Path getFile() {
return file;
@Override
public SchematicType getType() {
return SchematicType.LITEMATIC;
}
public int getVersion() {
return version;
@Override
public OptionalInt getVersion() {
return OptionalInt.of(version);
}
public int getSubVersion() {
return subVersion;
}
public int getMinecraftDataVersion() {
return minecraftDataVersion;
@Override
public OptionalInt getSubVersion() {
return subVersion >= 0 ? OptionalInt.of(subVersion) : OptionalInt.empty();
}
@Override
public int[] getPreviewImageData() {
return previewImageData != null ? previewImageData.clone() : null;
}
public String getName() {
return name;
@Override
public @NotNull String getName() {
return StringUtils.isNotBlank(name) ? name : super.getName();
}
@Override
public String getAuthor() {
return author;
}
@@ -151,27 +148,29 @@ public final class LitematicFile {
return description;
}
@Override
public Instant getTimeCreated() {
return timeCreated;
}
@Override
public Instant getTimeModified() {
return timeModified;
}
public int getTotalBlocks() {
return totalBlocks;
@Override
public OptionalInt getTotalBlocks() {
return totalBlocks >= 1 ? OptionalInt.of(totalBlocks) : OptionalInt.empty();
}
public int getTotalVolume() {
return totalVolume;
@Override
public OptionalLong getTotalVolume() {
return totalVolume >= 1 ? OptionalLong.of(totalVolume) : OptionalLong.empty();
}
public Point3D getEnclosingSize() {
return enclosingSize;
@Override
public OptionalInt getRegionCount() {
return regionCount >= 1 ? OptionalInt.of(regionCount) : OptionalInt.empty();
}
public int getRegionCount() {
return regionCount;
}
}
@@ -0,0 +1,25 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import com.google.gson.annotations.SerializedName;
public record LitematicaConfig(@SerializedName("Generic") Generic generic) {
public record Generic(boolean customSchematicBaseDirectoryEnabled, String customSchematicBaseDirectory) {
}
}
@@ -0,0 +1,83 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import org.glavo.nbt.tag.CompoundTag;
import org.glavo.nbt.tag.ListTag;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.util.NBTUtils;
import org.jackhuang.hmcl.util.Point3I;
import java.io.IOException;
import java.nio.file.Path;
import static org.jackhuang.hmcl.util.NBTUtils.tryGetInt;
/// @author Calboot
/// @see <a href="https://minecraft.wiki/w/Structure_file">Structure File</a>
public final class NBTStructureFile extends Schematic {
public static NBTStructureFile load(Path file) throws IOException {
CompoundTag root = NBTUtils.readCompressed(file);
if (!(root.get("blocks") instanceof ListTag<?>))
throw new IOException("blocks tag not found");
if (!(root.get("entities") instanceof ListTag<?>))
throw new IOException("entities tag not found");
Tag sizeTag = root.get("size");
if (sizeTag == null)
throw new IOException("size tag not found");
else if (!(sizeTag instanceof ListTag<?>))
throw new IOException("size tag is not a list");
ListTag<?> size = (ListTag<?>) sizeTag;
if (size.size() != 3)
throw new IOException("size tag does not have 3 elements");
Tag xTag = size.getTag(0);
Tag yTag = size.getTag(1);
Tag zTag = size.getTag(2);
Point3I enclosingSize = null;
int width = tryGetInt(xTag).orElse(0);
int height = tryGetInt(yTag).orElse(0);
int length = tryGetInt(zTag).orElse(0);
if (width > 0 && height > 0 && length > 0) {
enclosingSize = new Point3I(width, height, length);
}
return new NBTStructureFile(file, tryGetInt(root.get("DataVersion")).orElse(500), NBTUtils.tryGetString(root.get("author")), enclosingSize);
}
private final String author;
private NBTStructureFile(Path file, int dataVersion, String author, Point3I enclosingSize) {
super(file, dataVersion, enclosingSize);
this.author = author;
}
@Override
public SchematicType getType() {
return SchematicType.NBT_STRUCTURE;
}
@Override
public String getAuthor() {
return author;
}
}
@@ -0,0 +1,152 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import org.glavo.nbt.tag.CompoundTag;
import org.glavo.nbt.tag.IntTag;
import org.glavo.nbt.tag.StringTag;
import org.glavo.nbt.tag.Tag;
import org.jackhuang.hmcl.util.NBTUtils;
import org.jackhuang.hmcl.util.Point3I;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.util.OptionalInt;
import static org.jackhuang.hmcl.util.NBTUtils.tryGetShort;
import static org.jackhuang.hmcl.util.NBTUtils.tryGetString;
/// @author Calboot
/// @see <a href="https://minecraft.wiki/w/Schematic_file_format">Schematic File Format Wiki</a>
/// @see <a href="https://github.com/Lunatrius/Schematica/blob/master/src/main/java/com/github/lunatrius/schematica/world/schematic/SchematicAlpha.java">Schematica</a>
/// @see <a href="https://github.com/SpongePowered/Schematic-Specification/tree/master/versions">Sponge Spec</a>
public final class SchemFile extends Schematic {
private static final int DATA_VERSION_MC_1_13_2 = 1631;
public static SchemFile load(Path file) throws IOException {
CompoundTag root = NBTUtils.readCompressed(file);
if (root.get("Materials") != null) return loadLegacy(file, root);
else if (root.get("Version") != null) return loadSponge(file, root);
else {
var schem = root.get("Schematic");
if (schem instanceof CompoundTag sponge3 && sponge3.get("Version") != null) {
return loadSponge(file, sponge3);
}
}
throw new IOException("No Materials tag or Version tag found");
}
private static SchemFile loadLegacy(Path file, CompoundTag root) throws IOException {
Tag materialsTag = root.get("Materials");
if (!(materialsTag instanceof StringTag))
throw new IOException("Materials tag is not a string");
Tag widthTag = root.get("Width");
Tag heightTag = root.get("Height");
Tag lengthTag = root.get("Length");
Point3I enclosingSize = null;
if (widthTag != null && heightTag != null && lengthTag != null) {
short width = tryGetShort(widthTag).orElse((short) 0);
short height = tryGetShort(heightTag).orElse((short) 0);
short length = tryGetShort(lengthTag).orElse((short) 0);
if (width > 0 && height > 0 && length > 0) {
enclosingSize = new Point3I(width, height, length);
}
}
return new SchemFile(file, null, null, ((StringTag) materialsTag).getValue(), 0, 0, enclosingSize);
}
private static SchemFile loadSponge(Path file, CompoundTag root) throws IOException {
Tag versionTag = root.get("Version");
if (!(versionTag instanceof IntTag))
throw new IOException("Version tag is not an integer");
Tag dataVersionTag = root.get("DataVersion");
int dataVersion;
dataVersion = dataVersionTag == null ? DATA_VERSION_MC_1_13_2 : NBTUtils.tryGetInt(dataVersionTag).orElse(0);
Tag widthTag = root.get("Width");
Tag heightTag = root.get("Height");
Tag lengthTag = root.get("Length");
Point3I enclosingSize = null;
if (widthTag != null && heightTag != null && lengthTag != null) {
int width = tryGetShort(widthTag).orElse((short) 0) & 0xFFFF;
int height = tryGetShort(heightTag).orElse((short) 0) & 0xFFFF;
int length = tryGetShort(lengthTag).orElse((short) 0) & 0xFFFF;
if (width > 0 && height > 0 && length > 0) {
enclosingSize = new Point3I(width, height, length);
}
}
Tag metaTag = root.get("Metadata");
String name = null, author = null;
if (metaTag instanceof CompoundTag meta) {
name = tryGetString(meta.get("Name"));
author = tryGetString(meta.get("Author"));
}
return new SchemFile(file, name, author, null, dataVersion, ((IntTag) versionTag).getValue(), enclosingSize);
}
private final @Nullable String name;
private final @Nullable String author;
private final @Nullable String materials;
private final int version;
private SchemFile(Path file, @Nullable String name, @Nullable String author, @Nullable String materials, int dataVersion, int version, Point3I enclosingSize) {
super(file, dataVersion, enclosingSize);
this.name = name;
this.author = author;
this.materials = materials;
this.version = version;
}
@Override
public @NotNull String getName() {
if (name == null) return super.getName();
return name;
}
@Override
public String getAuthor() {
if (author == null) return super.getAuthor();
return author;
}
@Override
public SchematicType getType() {
return SchematicType.SCHEM;
}
@Override
public OptionalInt getVersion() {
return version > 0 ? OptionalInt.of(version) : OptionalInt.empty();
}
@Override
public String getMinecraftVersion() {
return materials == null ? super.getMinecraftVersion() : materials;
}
}
@@ -0,0 +1,128 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import org.jackhuang.hmcl.util.Point3I;
import org.jackhuang.hmcl.util.io.FileUtils;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.OptionalInt;
import java.util.OptionalLong;
public sealed abstract class Schematic permits LitematicFile, SchemFile, NBTStructureFile {
public static boolean isFileSchematic(Path file) {
return SchematicType.getType(file) != null;
}
@Nullable
public static Schematic load(Path file) throws IOException {
var type = SchematicType.getType(file);
if (type == null) return null;
return switch (type) {
case LITEMATIC -> LitematicFile.load(file);
case SCHEM -> SchemFile.load(file);
case NBT_STRUCTURE -> NBTStructureFile.load(file);
};
}
private final Path file;
private final int dataVersion;
private final Point3I enclosingSize;
protected Schematic(Path file, int dataVersion, @Nullable Point3I enclosingSize) {
this.file = file;
this.dataVersion = dataVersion;
this.enclosingSize = enclosingSize;
}
public abstract SchematicType getType();
@NotNull
public Path getFile() {
return file;
}
public OptionalInt getVersion() {
return OptionalInt.empty();
}
/// Non-negative, otherwise empty
public OptionalInt getSubVersion() {
return OptionalInt.empty();
}
/// At least 100, otherwise empty
public OptionalInt getMinecraftDataVersion() {
/* 15w32a */
return dataVersion >= 100 ? OptionalInt.of(dataVersion) : OptionalInt.empty();
}
@Nullable
public String getMinecraftVersion() {
return getMinecraftDataVersion().isPresent() ? Integer.toString(getMinecraftDataVersion().getAsInt()) : null;
}
@NotNull
public String getName() {
return FileUtils.getNameWithoutExtension(getFile());
}
@Nullable
public String getAuthor() {
return null;
}
@Nullable
public Instant getTimeCreated() {
return null;
}
@Nullable
public Instant getTimeModified() {
return null;
}
public OptionalInt getRegionCount() {
return OptionalInt.empty();
}
public OptionalInt getTotalBlocks() {
return OptionalInt.empty();
}
@Nullable
public Point3I getEnclosingSize() {
return enclosingSize;
}
public OptionalLong getTotalVolume() {
var enclosingSize = getEnclosingSize();
if (enclosingSize != null) return OptionalLong.of((long) enclosingSize.x() * enclosingSize.y() * enclosingSize.z());
return OptionalLong.empty();
}
public int @Nullable [] getPreviewImageData() {
return null;
}
}
@@ -0,0 +1,45 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import org.jackhuang.hmcl.util.io.FileUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
public enum SchematicType {
LITEMATIC("litematic"),
NBT_STRUCTURE("nbt"),
SCHEM("schematic", "schem");
public static SchematicType getType(Path file) {
if (file == null || !Files.isRegularFile(file)) return null;
String ext = FileUtils.getExtension(file);
for (SchematicType type : values()) {
if (type.extensions.contains(ext)) return type;
}
return null;
}
public final List<String> extensions;
SchematicType(String... ext) {
this.extensions = List.of(ext);
}
}
@@ -48,6 +48,23 @@ public final class DigestUtils {
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);
@@ -0,0 +1,79 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
import org.glavo.nbt.io.NBTCodec;
import org.glavo.nbt.tag.*;
import org.jetbrains.annotations.Nullable;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Instant;
import java.util.Optional;
import java.util.OptionalInt;
import java.util.OptionalLong;
public final class NBTUtils {
public static CompoundTag readCompressed(Path file) throws IOException {
return NBTCodec.of().readTag(file, TagType.COMPOUND);
}
public static OptionalLong tryGetLong(Tag tag) {
if (tag instanceof ValueTag<?> valueTag) {
Object val = valueTag.getValue();
if (val instanceof Long || val instanceof Integer || val instanceof Short || val instanceof Byte) {
return OptionalLong.of(((Number) val).longValue());
}
}
return OptionalLong.empty();
}
public static OptionalInt tryGetInt(Tag tag) {
var ol = tryGetLong(tag);
if (ol.isEmpty()) return OptionalInt.empty();
long l = ol.getAsLong();
int i = (int) l;
if (l == i) return OptionalInt.of(i);
return OptionalInt.empty();
}
public static Optional<Short> tryGetShort(Tag tag) {
var ol = tryGetLong(tag);
if (ol.isEmpty()) return Optional.empty();
long l = ol.getAsLong();
short i = (short) l;
if (l == i) return Optional.of(i);
return Optional.empty();
}
public static @Nullable Instant tryGetLongTimestamp(Tag tag) {
if (tag instanceof LongTag longTag) {
return Instant.ofEpochMilli(longTag.get());
}
return null;
}
public static @Nullable String tryGetString(Tag tag) {
return tag instanceof StringTag stringTag ? stringTag.get() : null;
}
private NBTUtils() {
}
}
@@ -0,0 +1,24 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2026 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.util;
public record Point3I(int x, int y, int z) {
public static final Point3I ZERO = new Point3I(0, 0, 0);
}
@@ -949,4 +949,5 @@
26.3-pre-3
26.3-rc-1
26.3-rc-2
26.3-rc-3
26.3
@@ -48,6 +48,7 @@ import java.util.List;
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 final class DefaultGameInstanceTest {
DefaultDependencyManager dependencyManager,
Path zipFile,
GameInstanceID instanceId,
String iconUrl) {
String iconUrl,
@Nullable Set<String> excludedFiles) {
throw new UnsupportedOperationException();
}
};
@@ -1,53 +0,0 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import javafx.geometry.Point3D;
import org.jackhuang.hmcl.game.CrashReportAnalyzerTest;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Paths;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
public final class LitematicFileTest {
private static LitematicFile load(String name) throws IOException, URISyntaxException {
URL resource = CrashReportAnalyzerTest.class.getResource(name);
if (resource == null)
throw new IOException("Resource not found: " + name);
return LitematicFile.load(Paths.get(resource.toURI()));
}
@Test
public void test() throws Exception {
LitematicFile file = load("/schematics/test.litematic");
assertEquals("刷石机一桶岩浆下推爆破8.3万每小时", file.getName());
assertEquals("hsds", file.getAuthor());
assertEquals("", file.getDescription());
assertEquals(Instant.ofEpochMilli(1746443586433L), file.getTimeCreated());
assertEquals(Instant.ofEpochMilli(1746443586433L), file.getTimeModified());
assertEquals(1334, file.getTotalBlocks());
assertEquals(5746, file.getTotalVolume());
assertEquals(new Point3D(17, 26, 13), file.getEnclosingSize());
assertEquals(1, file.getRegionCount());
}
}
@@ -0,0 +1,88 @@
/*
* Hello Minecraft! Launcher
* Copyright (C) 2025 huangyuhui <huanghongxun2008@126.com> 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 <https://www.gnu.org/licenses/>.
*/
package org.jackhuang.hmcl.schematic;
import org.jackhuang.hmcl.game.CrashReportAnalyzerTest;
import org.jackhuang.hmcl.util.Point3I;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.file.Paths;
import java.time.Instant;
import static org.junit.jupiter.api.Assertions.assertEquals;
public final class SchematicTest {
private static Schematic load(String name) throws IOException, URISyntaxException {
URL resource = CrashReportAnalyzerTest.class.getResource(name);
if (resource == null)
throw new IOException("Resource not found: " + name);
return Schematic.load(Paths.get(resource.toURI()));
}
@Test
public void test() throws Exception {
{
LitematicFile lFile = (LitematicFile) load("/schematics/test.litematic");
assertEquals(SchematicType.LITEMATIC, lFile.getType());
assertEquals("刷石机一桶岩浆下推爆破8.3万每小时", lFile.getName());
assertEquals("hsds", lFile.getAuthor());
assertEquals("", lFile.getDescription());
assertEquals(Instant.ofEpochMilli(1746443586433L), lFile.getTimeCreated());
assertEquals(Instant.ofEpochMilli(1746443586433L), lFile.getTimeModified());
assertEquals(1334, lFile.getTotalBlocks().orElse(0));
assertEquals(5746, lFile.getTotalVolume().orElse(0));
assertEquals(new Point3I(17, 26, 13), lFile.getEnclosingSize());
assertEquals(1, lFile.getRegionCount().orElse(0));
assertEquals(4325, lFile.getMinecraftDataVersion().orElse(0));
assertEquals("4325", lFile.getMinecraftVersion());
assertEquals(7, lFile.getVersion().orElse(0));
assertEquals(1, lFile.getSubVersion().orElse(-1));
}
{
SchemFile sFile = (SchemFile) load("/schematics/test.schematic");
assertEquals(SchematicType.SCHEM, sFile.getType());
assertEquals("test", sFile.getName());
assertEquals(new Point3I(28, 35, 18), sFile.getEnclosingSize());
assertEquals(17640, sFile.getTotalVolume().orElse(0));
assertEquals("Alpha", sFile.getMinecraftVersion());
}
{
SchemFile sFileSponge = (SchemFile) load("/schematics/test.schem");
assertEquals(SchematicType.SCHEM, sFileSponge.getType());
assertEquals("test", sFileSponge.getName());
assertEquals(3465, sFileSponge.getMinecraftDataVersion().orElse(0));
assertEquals("3465", sFileSponge.getMinecraftVersion());
assertEquals(new Point3I(9, 5, 9), sFileSponge.getEnclosingSize());
}
{
NBTStructureFile nFile = (NBTStructureFile) load("/schematics/test.nbt");
assertEquals(SchematicType.NBT_STRUCTURE, nFile.getType());
assertEquals("test", nFile.getName());
assertEquals(new Point3I(9, 11, 13), nFile.getEnclosingSize());
assertEquals(1287, nFile.getTotalVolume().orElse(0));
assertEquals(3465, nFile.getMinecraftDataVersion().orElse(0));
assertEquals("3465", nFile.getMinecraftVersion());
}
}
}
Binary file not shown.
Binary file not shown.