diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java index 8a15898173..1e32436ee0 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/game/LocalizedRemoteModRepository.java @@ -18,7 +18,6 @@ package org.jackhuang.hmcl.game; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.mod.RemoteModRepository; import org.jackhuang.hmcl.ui.versions.ModTranslations; @@ -110,8 +109,8 @@ public Stream getCategories() throws IOException { } @Override - public Optional getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException { - return getBackedRemoteModRepository().getRemoteVersionByLocalFile(localModFile, file); + public Optional getRemoteVersionByLocalFile(Path file) throws IOException { + return getBackedRemoteModRepository().getRemoteVersionByLocalFile(file); } @Override diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java index 1c43366e4f..a5d1e2b80f 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/setting/GlobalConfig.java @@ -148,6 +148,17 @@ public ObservableSet getDisabledJava() { return disabledJava; } + @SerializedName("resourcePackWarningShown") + private final BooleanProperty resourcePackWarningShown = new SimpleBooleanProperty(false); + + public boolean isResourcePackWarningShown() { + return resourcePackWarningShown.get(); + } + + public void onResourcePackWarningShown() { + resourcePackWarningShown.set(true); + } + static final class Adapter extends ObservableSetting.Adapter { @Override protected GlobalConfig createInstance() { diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java index aa17a4504c..13cb7fdec1 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/download/DownloadPage.java @@ -63,6 +63,13 @@ import static org.jackhuang.hmcl.util.i18n.I18n.i18n; public class DownloadPage extends DecoratorAnimatedPage implements DecoratorPage { + public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_MOD = + (profile, version, mod, file) -> download(profile, version, file, "mods"); + public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_RESOURCE_PACK = + (profile, version, mod, file) -> download(profile, version, file, "resourcepacks"); + public static final org.jackhuang.hmcl.ui.versions.DownloadPage.DownloadCallback FOR_SHADER = + (profile, version, mod, file) -> download(profile, version, file, "shaderpacks"); + private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("download"), -1)); private final TabHeader tab; private final TabHeader.Tab newGameTab = new TabHeader.Tab<>("newGameTab"); @@ -94,9 +101,9 @@ public DownloadPage(String uploadVersion) { page.getActions().add(installLocalModpackButton); return page; })); - modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod((profile, version, mod, file) -> download(profile, version, file, "mods"), true))); - resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack((profile, version, mod, file) -> download(profile, version, file, "resourcepacks"), true))); - shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack((profile, version, mod, file) -> download(profile, version, file, "shaderpacks"), true))); + modTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofMod(FOR_MOD, true))); + resourcePackTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofResourcePack(FOR_RESOURCE_PACK, true))); + shaderTab.setNodeSupplier(loadVersionFor(() -> HMCLLocalizedDownloadListPage.ofShaderPack(FOR_SHADER, true))); worldTab.setNodeSupplier(loadVersionFor(() -> new DownloadListPage(CurseForgeRemoteModRepository.WORLDS))); tab = new TabHeader(transitionPane, newGameTab, modpackTab, modTab, resourcePackTab, shaderTab, worldTab); @@ -195,7 +202,7 @@ public void showModpackDownloads() { tab.select(modpackTab, false); } - public void showResourcepackDownloads() { + public void showResourcePackDownloads() { tab.select(resourcePackTab, false); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModCheckUpdatesTask.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/CheckUpdatesTask.java similarity index 64% rename from HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModCheckUpdatesTask.java rename to HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/CheckUpdatesTask.java index 86806951c6..f838191ec5 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModCheckUpdatesTask.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/CheckUpdatesTask.java @@ -17,36 +17,39 @@ */ package org.jackhuang.hmcl.ui.versions; -import org.jackhuang.hmcl.mod.LocalModFile; +import org.jackhuang.hmcl.mod.LocalAddonFile; import org.jackhuang.hmcl.mod.RemoteMod; +import org.jackhuang.hmcl.mod.RemoteModRepository; import org.jackhuang.hmcl.task.Task; import java.util.*; import java.util.stream.Collectors; -public class ModCheckUpdatesTask extends Task> { - private final String gameVersion; - private final Collection mods; - private final Collection>> dependents; - - public ModCheckUpdatesTask(String gameVersion, Collection mods) { - this.gameVersion = gameVersion; - this.mods = mods; +public class CheckUpdatesTask extends Task> { + private final Collection>> dependents; + public CheckUpdatesTask(String gameVersion, Collection mods, RemoteModRepository.Type repoType) { + Map repos = new LinkedHashMap<>(2); + for (RemoteMod.Type modType : RemoteMod.Type.values()) { + RemoteModRepository repo = modType.getRepoForType(repoType); + if (repo != null) { + repos.put(modType.name(), repo); + } + } dependents = mods.stream() .map(mod -> - Arrays.stream(RemoteMod.Type.values()) - .map(type -> - Task.supplyAsync(() -> mod.checkUpdates(gameVersion, type.getRemoteModRepository())) + repos.entrySet().stream() + .map(entry -> + Task.supplyAsync(() -> mod.checkUpdates(gameVersion, entry.getValue())) .setSignificance(TaskSignificance.MAJOR) - .setName(String.format("%s (%s)", mod.getFileName(), type.name())).withCounter("update.checking") + .setName(String.format("%s (%s)", mod.getFileName(), entry.getKey())).withCounter("update.checking") ) .collect(Collectors.toList()) ) .collect(Collectors.toList()); setStage("update.checking"); - getProperties().put("total", dependents.size() * RemoteMod.Type.values().length); + getProperties().put("total", dependents.size() * repos.size()); } @Override @@ -75,8 +78,8 @@ public void execute() throws Exception { .map(tasks -> tasks.stream() .filter(task -> task.getResult() != null) .map(Task::getResult) - .filter(modUpdate -> !modUpdate.getCandidates().isEmpty()) - .max(Comparator.comparing((LocalModFile.ModUpdate modUpdate) -> modUpdate.getCandidates().get(0).getDatePublished())) + .filter(modUpdate -> !modUpdate.candidates().isEmpty()) + .max(Comparator.comparing((LocalAddonFile.ModUpdate modUpdate) -> modUpdate.candidates().get(0).getDatePublished())) .orElse(null) ) .filter(Objects::nonNull) diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java index 7b9e3ba364..d7d0cb871a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/DownloadPage.java @@ -73,6 +73,7 @@ public class DownloadPage extends Control implements DecoratorPage { private final Profile.ProfileVersion version; private final DownloadCallback callback; private final DownloadListPage page; + private final RemoteModRepository.Type type; private SimpleMultimap> versions; @@ -80,7 +81,8 @@ public DownloadPage(DownloadListPage page, RemoteMod addon, Profile.ProfileVersi this.page = page; this.repository = page.repository; this.addon = addon; - this.translations = ModTranslations.getTranslationsByRepositoryType(repository.getType()); + this.type = Objects.requireNonNullElse(addon.getRepositoryType(), repository.getType()); + this.translations = ModTranslations.getTranslationsByRepositoryType(this.type); this.mod = translations.getModByCurseForgeId(addon.getSlug()); this.version = version; this.callback = callback; @@ -159,13 +161,13 @@ public void setFailed(boolean failed) { public void download(RemoteMod mod, RemoteMod.Version file) { if (this.callback == null) { - saveAs(mod, file); + saveAs(file); } else { this.callback.download(version.getProfile(), version.getVersion(), mod, file); } } - public void saveAs(RemoteMod mod, RemoteMod.Version file) { + public void saveAs(RemoteMod.Version file) { String extension = StringUtils.substringAfterLast(file.getFile().getFilename(), '.'); FileChooser fileChooser = new FileChooser(); @@ -283,40 +285,46 @@ protected ModDownloadPageSkin(DownloadPage control) { resolve: for (RemoteMod.Version modVersion : modVersions) { - for (ModLoaderType loader : modVersion.getLoaders()) { - if (targetLoaders.contains(loader)) { - list.getContent().addAll( - ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)), - new ModItem(control.addon, modVersion, control) - ); - break resolve; + if (getSkinnable().type == RemoteModRepository.Type.MOD) { + for (ModLoaderType loader : modVersion.getLoaders()) { + if (targetLoaders.contains(loader)) { + list.getContent().addAll( + ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)), + new ModItem(control.addon, modVersion, control) + ); + break resolve; + } } + } else { + list.getContent().addAll( + ComponentList.createComponentListTitle(i18n("mods.download.recommend", gameVersion)), + new ModItem(control.addon, modVersion, control) + ); + break; } } } } } - for (String gameVersion : control.versions.keys().stream() + control.versions.keys().stream() .sorted(Collections.reverseOrder(GameVersionNumber::compare)) - .collect(Collectors.toList())) { - List versions = control.versions.get(gameVersion); - if (versions == null || versions.isEmpty()) { - continue; - } - - ComponentList sublist = new ComponentList(() -> { - ArrayList items = new ArrayList<>(versions.size()); - for (RemoteMod.Version v : versions) { - items.add(new ModItem(control.addon, v, control)); - } - return items; - }); - sublist.getStyleClass().add("no-padding"); - sublist.setTitle("Minecraft " + gameVersion); - - list.getContent().add(sublist); - } + .forEach(gameVersion -> { + List versions = control.versions.get(gameVersion); + if (versions == null || versions.isEmpty()) { + return; + } + ComponentList sublist = new ComponentList(() -> { + ArrayList items = new ArrayList<>(versions.size()); + for (RemoteMod.Version v : versions) { + items.add(new ModItem(control.addon, v, control)); + } + return items; + }); + sublist.getStyleClass().add("no-padding"); + sublist.setTitle("Minecraft " + gameVersion); + list.getContent().add(sublist); + }); }); } @@ -335,7 +343,7 @@ private static final class DependencyModItem extends StackPane { Pair.pair(RemoteMod.DependencyType.BROKEN, "mods.dependency.broken") )); - DependencyModItem(DownloadListPage page, RemoteMod addon, Profile.ProfileVersion version, DownloadCallback callback) { + DependencyModItem(DownloadListPage page, RemoteMod addon, Profile.ProfileVersion version) { HBox pane = new HBox(8); pane.setPadding(new Insets(0, 8, 0, 8)); pane.setAlignment(Pos.CENTER_LEFT); @@ -346,6 +354,15 @@ private static final class DependencyModItem extends StackPane { imageView.setFitHeight(40); pane.getChildren().setAll(FXUtils.limitingSize(imageView, 40, 40), content); + RemoteModRepository.Type type = addon.getRepositoryType(); + + DownloadCallback callback = switch (type) { + case MOD -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD; + case RESOURCE_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK; + case SHADER_PACK -> org.jackhuang.hmcl.ui.download.DownloadPage.FOR_SHADER; + default -> null; // Dependencies should not be modpacks, worlds or customized stuff + }; + RipplerContainer container = new RipplerContainer(pane); FXUtils.onClicked(container, () -> { fireEvent(new DialogCloseEvent()); @@ -354,7 +371,7 @@ private static final class DependencyModItem extends StackPane { getChildren().setAll(container); if (addon != RemoteMod.BROKEN) { - ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(page.repository.getType()).getModByCurseForgeId(addon.getSlug()); + ModTranslations.Mod mod = ModTranslations.getTranslationsByRepositoryType(type).getModByCurseForgeId(addon.getSlug()); content.setTitle(mod != null && I18n.isUseChinese() ? mod.getDisplayName() : addon.getTitle()); content.setSubtitle(addon.getDescription()); for (String category : addon.getCategories()) { @@ -446,7 +463,7 @@ private static final class ModItem extends StackPane { private static final class ModVersion extends JFXDialogLayout { public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPage) { - RemoteModRepository.Type type = selfPage.repository.getType(); + RemoteModRepository.Type type = selfPage.type; String title = switch (type) { case WORLD -> "world.download.title"; @@ -495,7 +512,7 @@ public ModVersion(RemoteMod mod, RemoteMod.Version version, DownloadPage selfPag if (!spinnerPane.isLoading() && spinnerPane.getFailedReason() == null) { fireEvent(new DialogCloseEvent()); } - selfPage.saveAs(mod, version); + selfPage.saveAs(version); }); JFXButton cancelButton = new JFXButton(i18n("button.cancel")); @@ -530,7 +547,7 @@ private void loadDependencies(RemoteMod.Version version, DownloadPage selfPage, list.add(title); dependencies.put(dependency.getType(), list); } - DependencyModItem dependencyModItem = new DependencyModItem(selfPage.page, dependency.load(), selfPage.version, selfPage.callback); + DependencyModItem dependencyModItem = new DependencyModItem(selfPage.page, dependency.load(), selfPage.version); dependencies.get(dependency.getType()).add(dependencyModItem); } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java index 0ef9f01ded..26793909ba 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPage.java @@ -28,6 +28,7 @@ import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.ModManager; +import org.jackhuang.hmcl.mod.RemoteModRepository; import org.jackhuang.hmcl.setting.Profile; import org.jackhuang.hmcl.task.Schedulers; import org.jackhuang.hmcl.task.Task; @@ -103,8 +104,8 @@ private void loadMods(ModManager modManager) { CompletableFuture.supplyAsync(() -> { lock.lock(); try { - modManager.refreshMods(); - return modManager.getMods().stream().map(ModListPageSkin.ModInfoObject::new).toList(); + modManager.refresh(); + return modManager.getLocalFiles().stream().map(ModListPageSkin.ModInfoObject::new).toList(); } catch (IOException e) { throw new UncheckedIOException(e); } finally { @@ -235,7 +236,7 @@ public void checkUpdates(Collection mods) { .composeAsync(() -> { Optional gameVersion = profile.getRepository().getGameVersion(instanceId); if (gameVersion.isPresent()) { - return new ModCheckUpdatesTask(gameVersion.get(), mods); + return new CheckUpdatesTask<>(gameVersion.get(), mods, RemoteModRepository.Type.MOD); } return null; }) @@ -245,7 +246,7 @@ public void checkUpdates(Collection mods) { } else if (result.isEmpty()) { Controllers.dialog(i18n("mods.check_updates.empty")); } else { - Controllers.navigateForward(new ModUpdatesPage(modManager, result)); + Controllers.navigateForward(new UpdatesPage<>(modManager, result)); } }) .withStagesHint(Collections.singletonList("update.checking")), diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java index c8d47870b2..3de71d9b41 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModListPageSkin.java @@ -465,7 +465,7 @@ final class ModInfoDialog extends JFXDialogLayout { RemoteModRepository repository = item.getValue(); JFXHyperlink button = new JFXHyperlink(i18n(item.getKey())); Task.runAsync(() -> { - Optional versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo(), modInfo.getModInfo().getFile()); + Optional versionOptional = repository.getRemoteVersionByLocalFile(modInfo.getModInfo().getFile()); if (versionOptional.isPresent()) { RemoteMod remoteMod = repository.getModById(versionOptional.get().getModid()); FXUtils.runInFX(() -> { @@ -506,7 +506,7 @@ final class ModInfoDialog extends JFXDialogLayout { repository instanceof CurseForgeRemoteModRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false), remoteMod, new Profile.ProfileVersion(ModListPageSkin.this.getSkinnable().getProfile(), ModListPageSkin.this.getSkinnable().getInstanceId()), - (profile, version, mod, file) -> org.jackhuang.hmcl.ui.download.DownloadPage.download(profile, version, file, "mods") + org.jackhuang.hmcl.ui.download.DownloadPage.FOR_MOD )); }); button.setDisable(false); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java new file mode 100644 index 0000000000..e06270c864 --- /dev/null +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcePackListPage.java @@ -0,0 +1,642 @@ +package org.jackhuang.hmcl.ui.versions; + +import com.jfoenix.controls.*; +import javafx.animation.PauseTransition; +import javafx.application.Platform; +import javafx.beans.binding.Bindings; +import javafx.beans.property.BooleanProperty; +import javafx.beans.property.SimpleBooleanProperty; +import javafx.css.PseudoClass; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Node; +import javafx.scene.control.*; +import javafx.scene.image.Image; +import javafx.scene.image.ImageView; +import javafx.scene.input.KeyCode; +import javafx.scene.input.KeyEvent; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.StackPane; +import javafx.stage.FileChooser; +import javafx.stage.Stage; +import javafx.util.Duration; +import org.jackhuang.hmcl.mod.*; +import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository; +import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository; +import org.jackhuang.hmcl.setting.ConfigHolder; +import org.jackhuang.hmcl.setting.Profile; +import org.jackhuang.hmcl.task.Schedulers; +import org.jackhuang.hmcl.task.Task; +import org.jackhuang.hmcl.ui.Controllers; +import org.jackhuang.hmcl.ui.FXUtils; +import org.jackhuang.hmcl.ui.ListPageBase; +import org.jackhuang.hmcl.ui.SVG; +import org.jackhuang.hmcl.ui.animation.ContainerAnimations; +import org.jackhuang.hmcl.ui.animation.TransitionPane; +import org.jackhuang.hmcl.ui.construct.*; +import org.jackhuang.hmcl.util.Pair; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.TaskCancellationAction; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Nullable; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +import static org.jackhuang.hmcl.ui.FXUtils.ignoreEvent; +import static org.jackhuang.hmcl.ui.FXUtils.onEscPressed; +import static org.jackhuang.hmcl.ui.ToolbarListPageSkin.createToolbarButton2; +import static org.jackhuang.hmcl.util.Pair.pair; +import static org.jackhuang.hmcl.util.i18n.I18n.i18n; +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class ResourcePackListPage extends ListPageBase implements VersionPage.VersionLoadable { + private static @Nullable String getWarningKey(ResourcePackFile.Compatibility compatibility) { + return switch (compatibility) { + case TOO_NEW -> "resourcepack.warning.too_new"; + case TOO_OLD -> "resourcepack.warning.too_old"; + case INVALID -> "resourcepack.warning.invalid"; + case MISSING_PACK_META -> "resourcepack.warning.missing_pack_meta"; + case MISSING_GAME_META -> "resourcepack.warning.missing_game_meta"; + default -> null; + }; + } + + private Profile profile; + private String instanceId; + + private Path resourcePackDirectory; + private ResourcePackManager resourcePackManager; + + public ResourcePackListPage() { + FXUtils.applyDragListener(this, ResourcePackFile::isFileResourcePack, this::addFiles); + } + + @Override + protected Skin createDefaultSkin() { + return new ResourcePackListPageSkin(this); + } + + @Override + public void loadVersion(Profile profile, String version) { + this.profile = profile; + this.instanceId = version; + this.resourcePackManager = new ResourcePackManager(profile.getRepository(), version); + this.resourcePackDirectory = this.resourcePackManager.getDirectory(); + + try { + if (!Files.exists(resourcePackDirectory)) { + Files.createDirectories(resourcePackDirectory); + } + } catch (IOException e) { + LOG.warning("Failed to create resource pack directory: " + resourcePackDirectory, e); + } + refresh(); + } + + public void refresh() { + if (resourcePackManager == null || !Files.isDirectory(resourcePackDirectory)) return; + setDisable(false); + if (!ResourcePackManager.isMcVersionSupported(resourcePackManager.getMinecraftVersion())) { + getItems().clear(); + setDisable(true); + return; + } + setLoading(true); + Task.supplyAsync(Schedulers.io(), () -> { + resourcePackManager.refresh(); + return resourcePackManager.getLocalFiles() + .stream() + .map(ResourcePackInfoObject::new) + .toList(); + }).whenComplete(Schedulers.javafx(), ((result, exception) -> { + if (exception == null) { + getItems().setAll(result); + } else { + LOG.warning("Failed to load resource packs", exception); + getItems().clear(); + } + setLoading(false); + })).start(); + } + + public void addFiles(List files) { + if (resourcePackManager == null) return; + + List failures = new ArrayList<>(); + for (Path file : files) { + try { + resourcePackManager.importResourcePack(file); + } catch (Exception e) { + LOG.warning("Failed to add resource pack", e); + failures.add(file); + } + } + if (!failures.isEmpty()) { + StringBuilder failure = new StringBuilder(i18n("resourcepack.add.failed")); + for (Path file: failures) { + failure.append(System.lineSeparator()).append(file.toString()); + } + Controllers.dialog(failure.toString(), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + } + + refresh(); + } + + public void onAddFiles() { + FileChooser fileChooser = new FileChooser(); + fileChooser.setTitle(i18n("resourcepack.add")); + fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("resourcepack"), "*.zip")); + List files = FileUtils.toPaths(fileChooser.showOpenMultipleDialog(Controllers.getStage())); + if (files != null && !files.isEmpty()) { + addFiles(files); + } + } + + private void onDownload() { + Controllers.getDownloadPage().showResourcePackDownloads(); + Controllers.navigate(Controllers.getDownloadPage()); + } + + private void onOpenFolder() { + if (resourcePackDirectory != null) { + FXUtils.openFolder(resourcePackDirectory); + } + } + + private void setSelectedEnabled(List selectedItems, boolean enabled) { + if (!ConfigHolder.globalConfig().isResourcePackWarningShown()) { + Controllers.confirmWithCountdown( + i18n("resourcepack.warning.manipulate"), + i18n("message.warning"), + 5, + MessageDialogPane.MessageType.WARNING, + () -> { + ConfigHolder.globalConfig().onResourcePackWarningShown(); + setSelectedEnabled(selectedItems, enabled); + }, null); + } else { + for (ResourcePackInfoObject item : selectedItems) { + item.enabledProperty().set(enabled); + } + } + } + + private void removeSelected(List selectedItems) { + try { + if (resourcePackManager != null) { + if (resourcePackManager.removeResourcePacks(selectedItems.stream().map(ResourcePackInfoObject::getFile).toList())) { + refresh(); + } + } + } catch (IOException e) { + Controllers.dialog(i18n("resourcepack.delete.failed", e.getMessage()), i18n("message.error"), MessageDialogPane.MessageType.ERROR); + LOG.warning("Failed to delete resource packs", e); + } + } + + public void checkUpdates() { + Runnable action = () -> Controllers.taskDialog(Task + .composeAsync(() -> { + Optional gameVersion = profile.getRepository().getGameVersion(instanceId); + if (gameVersion.isPresent()) { + return new CheckUpdatesTask<>(gameVersion.get(), resourcePackManager.getLocalFiles(), RemoteModRepository.Type.RESOURCE_PACK); + } + return null; + }) + .whenComplete(Schedulers.javafx(), (result, exception) -> { + if (exception != null || result == null) { + Controllers.dialog(i18n("mods.check_updates.failed_check"), i18n("message.failed"), MessageDialogPane.MessageType.ERROR); + } else if (result.isEmpty()) { + Controllers.dialog(i18n("mods.check_updates.empty")); + } else { + Controllers.navigateForward(new UpdatesPage<>(resourcePackManager, result)); + } + }) + .withStagesHint(Collections.singletonList("update.checking")), + i18n("mods.check_updates"), TaskCancellationAction.NORMAL); + + if (profile.getRepository().isModpack(instanceId)) { + Controllers.confirm( + i18n("mods.update_modpack_mod.warning"), null, + MessageDialogPane.MessageType.WARNING, + action, null); + } else { + action.run(); + } + } + + private static final class ResourcePackListPageSkin extends SkinBase { + private final JFXListView listView; + private final JFXTextField searchField = new JFXTextField(); + + private final TransitionPane toolbarPane = new TransitionPane(); + private final HBox searchBar = new HBox(); + private final HBox toolbarNormal = new HBox(); + private final HBox toolbarSelecting = new HBox(); + + private boolean isSearching; + + private ResourcePackListPageSkin(ResourcePackListPage control) { + super(control); + + 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<>(); + + { + + // Toolbar Selecting + toolbarSelecting.getChildren().setAll( + createToolbarButton2(i18n("button.remove"), SVG.DELETE, () -> { + Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), () -> { + control.removeSelected(listView.getSelectionModel().getSelectedItems()); + }, null); + }), + createToolbarButton2(i18n("button.enable"), SVG.CHECK, () -> + control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), true)), + createToolbarButton2(i18n("button.disable"), SVG.CLOSE, () -> + control.setSelectedEnabled(listView.getSelectionModel().getSelectedItems(), false)), + createToolbarButton2(i18n("button.select_all"), SVG.SELECT_ALL, () -> + listView.getSelectionModel().selectAll()), + createToolbarButton2(i18n("button.cancel"), SVG.CANCEL, () -> + listView.getSelectionModel().clearSelection()) + ); + + // Search Bar + searchBar.setAlignment(Pos.CENTER); + searchBar.setPadding(new Insets(0, 5, 0, 5)); + searchField.setPromptText(i18n("search")); + HBox.setHgrow(searchField, Priority.ALWAYS); + PauseTransition pause = new PauseTransition(Duration.millis(100)); + pause.setOnFinished(e -> search()); + searchField.textProperty().addListener((observable, oldValue, newValue) -> { + pause.setRate(1); + pause.playFromStart(); + }); + + JFXButton closeSearchBar = createToolbarButton2(null, SVG.CLOSE, + () -> { + changeToolbar(toolbarNormal); + + isSearching = false; + searchField.clear(); + Bindings.bindContent(listView.getItems(), getSkinnable().getItems()); + }); + + onEscPressed(searchField, closeSearchBar::fire); + + searchBar.getChildren().setAll(searchField, closeSearchBar); + + // Toolbar Normal + toolbarNormal.setAlignment(Pos.CENTER_LEFT); + toolbarNormal.setPickOnBounds(false); + toolbarNormal.getChildren().setAll( + createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, control::refresh), + createToolbarButton2(i18n("resourcepack.add"), SVG.ADD, control::onAddFiles), + createToolbarButton2(i18n("button.reveal_dir"), SVG.FOLDER_OPEN, control::onOpenFolder), + createToolbarButton2(i18n("mods.check_updates.button"), SVG.UPDATE, control::checkUpdates), + createToolbarButton2(i18n("download"), SVG.DOWNLOAD, control::onDownload), + createToolbarButton2(i18n("search"), SVG.SEARCH, () -> changeToolbar(searchBar)) + ); + + FXUtils.onChangeAndOperate(listView.getSelectionModel().selectedItemProperty(), + selectedItem -> { + if (selectedItem == null) + changeToolbar(isSearching ? searchBar : toolbarNormal); + else + changeToolbar(toolbarSelecting); + }); + root.getContent().add(toolbarPane); + + // Clear selection when pressing ESC + root.addEventHandler(KeyEvent.KEY_PRESSED, e -> { + if (e.getCode() == KeyCode.ESCAPE) { + if (listView.getSelectionModel().getSelectedItem() != null) { + listView.getSelectionModel().clearSelection(); + e.consume(); + } + } + }); + } + + { + SpinnerPane center = new SpinnerPane(); + ComponentList.setVgrow(center, Priority.ALWAYS); + center.getStyleClass().add("large-spinner-pane"); + center.loadingProperty().bind(control.loadingProperty()); + + listView.setCellFactory(x -> new ResourcePackListCell(listView, control)); + listView.getSelectionModel().setSelectionMode(SelectionMode.MULTIPLE); + Bindings.bindContent(listView.getItems(), control.getItems()); + + listView.setOnContextMenuRequested(event -> { + ResourcePackInfoObject selectedItem = listView.getSelectionModel().getSelectedItem(); + if (selectedItem != null && listView.getSelectionModel().getSelectedItems().size() == 1) { + listView.getSelectionModel().clearSelection(); + Controllers.dialog(new ResourcePackInfoDialog(control, selectedItem)); + } + }); + + ignoreEvent(listView, KeyEvent.KEY_PRESSED, e -> e.getCode() == KeyCode.ESCAPE); + + center.setContent(listView); + root.getContent().add(center); + } + + pane.getChildren().setAll(root); + getChildren().setAll(pane); + } + + private void changeToolbar(HBox newToolbar) { + Node oldToolbar = toolbarPane.getCurrentNode(); + if (newToolbar != oldToolbar) { + toolbarPane.setContent(newToolbar, ContainerAnimations.FADE); + if (newToolbar == searchBar) { + Platform.runLater(searchField::requestFocus); + } + } + } + + private void search() { + isSearching = true; + + Bindings.unbindContent(listView.getItems(), getSkinnable().getItems()); + + String queryString = searchField.getText(); + if (StringUtils.isBlank(queryString)) { + listView.getItems().setAll(getSkinnable().getItems()); + } else { + listView.getItems().clear(); + + Predicate<@Nullable String> predicate; + if (queryString.startsWith("regex:")) { + try { + Pattern pattern = Pattern.compile(queryString.substring("regex:".length())); + predicate = s -> s != null && pattern.matcher(s).find(); + } catch (Throwable e) { + LOG.warning("Illegal regular expression", e); + return; + } + } else { + String lowerQueryString = queryString.toLowerCase(Locale.ROOT); + predicate = s -> s != null && s.toLowerCase(Locale.ROOT).contains(lowerQueryString); + } + + // Do we need to search in the background thread? + for (ResourcePackInfoObject item : getSkinnable().getItems()) { + ResourcePackFile resourcePack = item.getFile(); + var description = resourcePack.getDescription(); + var descriptionParts = description == null + ? Stream.empty() + : description.getParts().stream().map(LocalModFile.Description.Part::getText); + if (predicate.test(resourcePack.getFileNameWithExtension()) + || predicate.test(resourcePack.getFileName()) + || descriptionParts.anyMatch(predicate)) { + listView.getItems().add(item); + } + } + } + } + } + + public static class ResourcePackInfoObject { + private final ResourcePackFile file; + private final BooleanProperty enabled; + private WeakReference iconCache; + + public ResourcePackInfoObject(ResourcePackFile file) { + this.file = file; + this.enabled = new SimpleBooleanProperty(this, "enabled", file.isEnabled()); + this.enabled.addListener(__ -> file.setEnabled(enabled.get())); + } + + public ResourcePackFile getFile() { + return file; + } + + public BooleanProperty enabledProperty() { + return enabled; + } + + Image getIcon() { + Image image = null; + if (iconCache != null && (image = iconCache.get()) != null) { + return image; + } + byte[] iconData = file.getIcon(); + if (iconData != null) { + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(iconData)) { + image = new Image(inputStream, 64, 64, true, true); + } catch (Exception e) { + LOG.warning("Failed to load resource pack icon " + file.getFile(), e); + } + } + + if (image == null || image.isError() || image.getWidth() <= 0 || image.getHeight() <= 0 || + (Math.abs(image.getWidth() - image.getHeight()) >= 1)) { + image = FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"); + } + iconCache = new WeakReference<>(image); + return image; + } + } + + private static final class ResourcePackListCell extends MDListCell { + private static final PseudoClass WARNING = PseudoClass.getPseudoClass("warning"); + + private final ResourcePackListPage page; + + private final JFXCheckBox checkBox; + private final ImageView imageView = new ImageView(); + private final TwoLineListItem content = new TwoLineListItem(); + private final JFXButton btnReveal = new JFXButton(); + private final JFXButton btnInfo = new JFXButton(); + + private Tooltip warningTooltip = null; + + private BooleanProperty booleanProperty = null; + + public ResourcePackListCell(JFXListView listView, ResourcePackListPage page) { + super(listView); + this.page = page; + + getStyleClass().add("resource-pack-list-cell"); + + HBox root = new HBox(8); + root.setPickOnBounds(false); + root.setAlignment(Pos.CENTER_LEFT); + + checkBox = new JFXCheckBox() { + @Override + public void fire() { + if (!ConfigHolder.globalConfig().isResourcePackWarningShown()) { + Controllers.confirmWithCountdown( + i18n("resourcepack.warning.manipulate"), + i18n("message.warning"), + 5, + MessageDialogPane.MessageType.WARNING, + () -> { + super.fire(); + ConfigHolder.globalConfig().onResourcePackWarningShown(); + }, null); + } else { + super.fire(); + } + } + }; + + imageView.setFitWidth(24); + imageView.setFitHeight(24); + imageView.setPreserveRatio(true); + + HBox.setHgrow(content, Priority.ALWAYS); + content.setMouseTransparent(true); + + btnReveal.getStyleClass().add("toggle-icon4"); + btnReveal.setGraphic(FXUtils.limitingSize(SVG.FOLDER.createIcon(24), 24, 24)); + + btnInfo.getStyleClass().add("toggle-icon4"); + btnInfo.setGraphic(FXUtils.limitingSize(SVG.INFO.createIcon(24), 24, 24)); + + root.getChildren().setAll(checkBox, imageView, content, btnReveal, btnInfo); + + setSelectable(); + + StackPane.setMargin(root, new Insets(8)); + getContainer().getChildren().add(root); + } + + @Override + protected void updateControl(ResourcePackInfoObject item, boolean empty) { + pseudoClassStateChanged(WARNING, false); + if (warningTooltip != null) { + Tooltip.uninstall(this, warningTooltip); + warningTooltip = null; + } + + if (empty || item == null) { + return; + } + + ResourcePackFile file = item.getFile(); + imageView.setImage(item.getIcon()); + + content.setTitle(file.getFileName()); + content.setSubtitle(file.getFileNameWithExtension()); + + FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager")); + btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getFile())); + + btnInfo.setOnAction(e -> Controllers.dialog(new ResourcePackInfoDialog(this.page, item))); + + if (booleanProperty != null) { + checkBox.selectedProperty().unbindBidirectional(booleanProperty); + } + checkBox.selectedProperty().bindBidirectional(booleanProperty = item.enabledProperty()); + + { + String warningKey = getWarningKey(file.getCompatibility()); + if (warningKey != null) { + pseudoClassStateChanged(WARNING, true); + FXUtils.installFastTooltip(this, warningTooltip = new Tooltip(i18n(warningKey))); + } + } + } + } + + private static final class ResourcePackInfoDialog extends JFXDialogLayout { + + ResourcePackInfoDialog(ResourcePackListPage page, ResourcePackInfoObject packInfoObject) { + ResourcePackFile pack = packInfoObject.getFile(); + + HBox titleContainer = new HBox(); + titleContainer.setSpacing(8); + + Stage stage = Controllers.getStage(); + maxWidthProperty().bind(stage.widthProperty().multiply(0.7)); + + ImageView imageView = new ImageView(); + imageView.setImage(packInfoObject.getIcon()); + FXUtils.limitSize(imageView, 40, 40); + + TwoLineListItem title = new TwoLineListItem(); + title.setTitle(pack.getFileName()); + title.setSubtitle(pack.getFileNameWithExtension()); + if (pack.getCompatibility() == ResourcePackFile.Compatibility.COMPATIBLE) { + title.addTag(i18n("resourcepack.compatible")); + } else { + title.addTagWarning(i18n(getWarningKey(packInfoObject.file.getCompatibility()))); + } + + titleContainer.getChildren().setAll(FXUtils.limitingSize(imageView, 40, 40), title); + setHeading(titleContainer); + + Label description = new Label(Objects.requireNonNullElse(pack.getDescription(), "").toString()); + description.setWrapText(true); + FXUtils.copyOnDoubleClick(description); + + ScrollPane descriptionPane = new ScrollPane(description); + FXUtils.smoothScrolling(descriptionPane); + descriptionPane.setHbarPolicy(ScrollPane.ScrollBarPolicy.NEVER); + descriptionPane.setVbarPolicy(ScrollPane.ScrollBarPolicy.AS_NEEDED); + descriptionPane.setFitToWidth(true); + description.heightProperty().addListener((obs, oldVal, newVal) -> { + double maxHeight = stage.getHeight() * 0.5; + double targetHeight = Math.min(newVal.doubleValue(), maxHeight); + descriptionPane.setPrefViewportHeight(targetHeight); + }); + + setBody(descriptionPane); + + for (Pair item : Arrays.asList( + pair("mods.curseforge", CurseForgeRemoteModRepository.RESOURCE_PACKS), + pair("mods.modrinth", ModrinthRemoteModRepository.RESOURCE_PACKS) + )) { + RemoteModRepository repository = item.getValue(); + JFXHyperlink button = new JFXHyperlink(i18n(item.getKey())); + Task.runAsync(() -> { + Optional versionOptional = repository.getRemoteVersionByLocalFile(packInfoObject.getFile().getFile()); + if (versionOptional.isPresent()) { + RemoteMod remoteMod = repository.getModById(versionOptional.get().getModid()); + FXUtils.runInFX(() -> { + button.setOnAction(e -> { + fireEvent(new DialogCloseEvent()); + Controllers.navigate(new DownloadPage( + repository instanceof CurseForgeRemoteModRepository ? HMCLLocalizedDownloadListPage.ofCurseForgeMod(null, false) : HMCLLocalizedDownloadListPage.ofModrinthMod(null, false), + remoteMod, + new Profile.ProfileVersion(page.profile, page.instanceId), + org.jackhuang.hmcl.ui.download.DownloadPage.FOR_RESOURCE_PACK + )); + }); + button.setDisable(false); + }); + } + }).start(); + button.setDisable(true); + getActions().add(button); + } + + 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); + } + } +} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcepackListPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcepackListPage.java deleted file mode 100644 index bd2b1da00f..0000000000 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ResourcepackListPage.java +++ /dev/null @@ -1,280 +0,0 @@ -package org.jackhuang.hmcl.ui.versions; - -import com.jfoenix.controls.JFXButton; -import com.jfoenix.controls.JFXListView; -import javafx.beans.binding.Bindings; -import javafx.geometry.Insets; -import javafx.geometry.Pos; -import javafx.scene.control.Skin; -import javafx.scene.control.SkinBase; -import javafx.scene.image.Image; -import javafx.scene.image.ImageView; -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 org.jackhuang.hmcl.mod.LocalModFile; -import org.jackhuang.hmcl.resourcepack.ResourcepackFile; -import org.jackhuang.hmcl.setting.Profile; -import org.jackhuang.hmcl.task.Schedulers; -import org.jackhuang.hmcl.task.Task; -import org.jackhuang.hmcl.ui.Controllers; -import org.jackhuang.hmcl.ui.FXUtils; -import org.jackhuang.hmcl.ui.ListPageBase; -import org.jackhuang.hmcl.ui.SVG; -import org.jackhuang.hmcl.ui.construct.*; -import org.jackhuang.hmcl.util.io.FileUtils; - -import java.io.ByteArrayInputStream; -import java.io.IOException; -import java.lang.ref.WeakReference; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Comparator; -import java.util.List; -import java.util.Objects; -import java.util.stream.Stream; - -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; - -public final class ResourcepackListPage extends ListPageBase implements VersionPage.VersionLoadable { - private Path resourcepackDirectory; - - public ResourcepackListPage() { - FXUtils.applyDragListener(this, file -> file.getFileName().toString().endsWith(".zip"), this::addFiles); - } - - @Override - protected Skin createDefaultSkin() { - return new ResourcepackListPageSkin(this); - } - - @Override - public void loadVersion(Profile profile, String version) { - this.resourcepackDirectory = profile.getRepository().getResourcepacksDirectory(version); - - try { - if (!Files.exists(resourcepackDirectory)) { - Files.createDirectories(resourcepackDirectory); - } - } catch (IOException e) { - LOG.warning("Failed to create resourcepack directory" + resourcepackDirectory, e); - } - refresh(); - } - - public void refresh() { - if (resourcepackDirectory == null || !Files.isDirectory(resourcepackDirectory)) return; - setLoading(true); - Task.supplyAsync(Schedulers.io(), () -> { - try (Stream stream = Files.list(resourcepackDirectory)) { - return stream.sorted(Comparator.comparing(FileUtils::getName)) - .flatMap(item -> { - try { - return Stream.of(ResourcepackFile.parse(item)).filter(Objects::nonNull).map(ResourcepackInfoObject::new); - } catch (IOException e) { - LOG.warning("Failed to load resourcepack " + item, e); - return Stream.empty(); - } - }) - .toList(); - } - }).whenComplete(Schedulers.javafx(), ((result, exception) -> { - if (exception == null) { - getItems().setAll(result); - } else { - LOG.warning("Failed to load resourcepacks", exception); - getItems().clear(); - } - setLoading(false); - })).start(); - } - - public void addFiles(List files) { - if (resourcepackDirectory == null) return; - - try { - for (Path file : files) { - Path target = resourcepackDirectory.resolve(file.getFileName()); - if (!Files.exists(target)) { - Files.copy(file, target); - } - } - } catch (IOException e) { - LOG.warning("Failed to add resourcepacks", e); - Controllers.dialog(i18n("resourcepack.add.failed"), i18n("message.error"), MessageDialogPane.MessageType.ERROR); - } - - refresh(); - } - - public void onAddFiles() { - FileChooser fileChooser = new FileChooser(); - fileChooser.setTitle(i18n("resourcepack.add")); - fileChooser.getExtensionFilters().add(new FileChooser.ExtensionFilter(i18n("resourcepack"), "*.zip")); - List files = FileUtils.toPaths(fileChooser.showOpenMultipleDialog(Controllers.getStage())); - if (files != null && !files.isEmpty()) { - addFiles(files); - } - } - - private void onDownload() { - Controllers.getDownloadPage().showResourcepackDownloads(); - Controllers.navigate(Controllers.getDownloadPage()); - } - - private static final class ResourcepackListPageSkin extends SkinBase { - private final JFXListView listView; - - private ResourcepackListPageSkin(ResourcepackListPage control) { - super(control); - - 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<>(); - - HBox toolbar = new HBox(); - toolbar.setAlignment(Pos.CENTER_LEFT); - toolbar.setPickOnBounds(false); - toolbar.getChildren().setAll( - createToolbarButton2(i18n("button.refresh"), SVG.REFRESH, control::refresh), - createToolbarButton2(i18n("resourcepack.add"), SVG.ADD, control::onAddFiles), - createToolbarButton2(i18n("resourcepack.download"), SVG.DOWNLOAD, control::onDownload) - ); - root.getContent().add(toolbar); - - SpinnerPane center = new SpinnerPane(); - ComponentList.setVgrow(center, Priority.ALWAYS); - center.getStyleClass().add("large-spinner-pane"); - center.loadingProperty().bind(control.loadingProperty()); - - listView.setCellFactory(x -> new ResourcepackListCell(listView, control)); - Bindings.bindContent(listView.getItems(), control.getItems()); - - center.setContent(listView); - root.getContent().add(center); - - pane.getChildren().setAll(root); - getChildren().setAll(pane); - } - } - - public static class ResourcepackInfoObject { - private final ResourcepackFile file; - private WeakReference iconCache; - - public ResourcepackInfoObject(ResourcepackFile file) { - this.file = file; - } - - public ResourcepackFile getFile() { - return file; - } - - Image getIcon() { - Image image = null; - if (iconCache != null && (image = iconCache.get()) != null) { - return image; - } - byte[] iconData = file.getIcon(); - if (iconData != null) { - try (ByteArrayInputStream inputStream = new ByteArrayInputStream(iconData)) { - image = new Image(inputStream, 64, 64, true, true); - } catch (Exception e) { - LOG.warning("Failed to load resourcepack icon " + file.getPath(), e); - } - } - - if (image == null || image.isError() || image.getWidth() <= 0 || image.getHeight() <= 0 || - (Math.abs(image.getWidth() - image.getHeight()) >= 1)) { - image = FXUtils.newBuiltinImage("/assets/img/unknown_pack.png"); - } - iconCache = new WeakReference<>(image); - return image; - } - } - - private static final class ResourcepackListCell extends MDListCell { - private final ImageView imageView = new ImageView(); - private final TwoLineListItem content = new TwoLineListItem(); - private final JFXButton btnReveal = new JFXButton(); - private final JFXButton btnDelete = new JFXButton(); - private final ResourcepackListPage page; - - public ResourcepackListCell(JFXListView listView, ResourcepackListPage page) { - super(listView); - - this.page = page; - - BorderPane root = new BorderPane(); - root.getStyleClass().add("md-list-cell"); - root.setPadding(new Insets(8)); - - HBox left = new HBox(8); - left.setAlignment(Pos.CENTER); - FXUtils.limitSize(imageView, 32, 32); - left.getChildren().add(imageView); - left.setPadding(new Insets(0, 8, 0, 0)); - FXUtils.setLimitWidth(left, 48); - root.setLeft(left); - - HBox.setHgrow(content, Priority.ALWAYS); - root.setCenter(content); - - btnReveal.getStyleClass().add("toggle-icon4"); - btnReveal.setGraphic(SVG.FOLDER_OPEN.createIcon()); - - btnDelete.getStyleClass().add("toggle-icon4"); - btnDelete.setGraphic(SVG.DELETE_FOREVER.createIcon()); - - HBox right = new HBox(8); - right.setAlignment(Pos.CENTER_RIGHT); - right.getChildren().setAll(btnReveal, btnDelete); - root.setRight(right); - - getContainer().getChildren().add(new RipplerContainer(root)); - } - - @Override - protected void updateControl(ResourcepackListPage.ResourcepackInfoObject item, boolean empty) { - if (empty || item == null) { - return; - } - - ResourcepackFile file = item.getFile(); - imageView.setImage(item.getIcon()); - - content.setTitle(file.getName()); - LocalModFile.Description description = file.getDescription(); - content.setSubtitle(description != null ? description.toString() : ""); - - FXUtils.installFastTooltip(btnReveal, i18n("reveal.in_file_manager")); - btnReveal.setOnAction(event -> FXUtils.showFileInExplorer(file.getPath())); - - btnDelete.setOnAction(event -> - Controllers.confirm(i18n("button.remove.confirm"), i18n("button.remove"), - () -> onDelete(file), null)); - } - - private void onDelete(ResourcepackFile file) { - try { - if (Files.isDirectory(file.getPath())) { - FileUtils.deleteDirectory(file.getPath()); - } else { - Files.delete(file.getPath()); - } - page.refresh(); - } catch (IOException e) { - Controllers.dialog(i18n("resourcepack.delete.failed", e.getMessage()), i18n("message.error"), MessageDialogPane.MessageType.ERROR); - LOG.warning("Failed to delete resourcepack", e); - } - } - } -} diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/UpdatesPage.java similarity index 85% rename from HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java rename to HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/UpdatesPage.java index abf8d68f6b..98a31fabf4 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/ModUpdatesPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/UpdatesPage.java @@ -29,7 +29,8 @@ import javafx.scene.control.TableView; import javafx.scene.layout.BorderPane; import javafx.scene.layout.HBox; -import org.jackhuang.hmcl.mod.LocalModFile; +import org.jackhuang.hmcl.mod.LocalAddonFile; +import org.jackhuang.hmcl.mod.LocalFileManager; import org.jackhuang.hmcl.mod.ModManager; import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.task.FileDownloadTask; @@ -45,6 +46,7 @@ import org.jackhuang.hmcl.util.TaskCancellationAction; import org.jackhuang.hmcl.util.io.CSVTable; +import java.io.IOException; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDateTime; @@ -59,15 +61,15 @@ import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.i18n.I18n.i18n; -public class ModUpdatesPage extends BorderPane implements DecoratorPage { +public class UpdatesPage extends BorderPane implements DecoratorPage { private final ReadOnlyObjectWrapper state = new ReadOnlyObjectWrapper<>(DecoratorPage.State.fromTitle(i18n("mods.check_updates"))); - private final ModManager modManager; + private final LocalFileManager localFileManager; private final ObservableList objects; @SuppressWarnings("unchecked") - public ModUpdatesPage(ModManager modManager, List updates) { - this.modManager = modManager; + public UpdatesPage(LocalFileManager localFileManager, List updates) { + this.localFileManager = localFileManager; getStyleClass().add("gray-background"); @@ -114,7 +116,7 @@ public ModUpdatesPage(ModManager modManager, List update exportListButton.setOnAction(e -> exportList()); JFXButton nextButton = FXUtils.newRaisedButton(i18n("mods.check_updates.confirm")); - nextButton.setOnAction(e -> updateMods()); + nextButton.setOnAction(e -> updateFiles()); JFXButton cancelButton = FXUtils.newRaisedButton(i18n("button.cancel")); cancelButton.setOnAction(e -> fireEvent(new PageCloseEvent())); @@ -129,19 +131,19 @@ private void setupCellValueFactory(TableColumn column, F column.setCellValueFactory(param -> mapper.apply(param.getValue())); } - private void updateMods() { - ModUpdateTask task = new ModUpdateTask( - modManager, + private void updateFiles() { + UpdateTask task = new UpdateTask( + localFileManager.getDirectory(), objects.stream() .filter(o -> o.enabled.get()) - .map(object -> pair(object.data.getLocalMod(), object.data.getCandidates().get(0))) - .collect(Collectors.toList())); + .map(object -> pair(object.data.localFile(), object.data.candidates().get(0))) + .toList()); Controllers.taskDialog( task.whenComplete(Schedulers.javafx(), exception -> { fireEvent(new PageCloseEvent()); if (!task.getFailedMods().isEmpty()) { Controllers.dialog(i18n("mods.check_updates.failed_download") + "\n" + - task.getFailedMods().stream().map(LocalModFile::getFileName).collect(Collectors.joining("\n")), + task.getFailedMods().stream().map(LocalAddonFile::getFileName).collect(Collectors.joining("\n")), i18n("install.failed"), MessageDialogPane.MessageType.ERROR); } @@ -190,21 +192,21 @@ public ReadOnlyObjectWrapper stateProperty() { } private static final class ModUpdateObject { - final LocalModFile.ModUpdate data; + final LocalAddonFile.ModUpdate data; final BooleanProperty enabled = new SimpleBooleanProperty(); final StringProperty fileName = new SimpleStringProperty(); final StringProperty currentVersion = new SimpleStringProperty(); final StringProperty targetVersion = new SimpleStringProperty(); final StringProperty source = new SimpleStringProperty(); - public ModUpdateObject(LocalModFile.ModUpdate data) { + public ModUpdateObject(LocalAddonFile.ModUpdate data) { this.data = data; - enabled.set(!data.getLocalMod().getModManager().isDisabled(data.getLocalMod().getFile())); - fileName.set(data.getLocalMod().getFileName()); - currentVersion.set(data.getCurrentVersion().getVersion()); - targetVersion.set(data.getCandidates().get(0).getVersion()); - switch (data.getCurrentVersion().getSelf().getType()) { + enabled.set(!data.localFile().isDisabled()); + fileName.set(data.localFile().getFileName()); + currentVersion.set(data.currentVersion().getVersion()); + targetVersion.set(data.candidates().get(0).getVersion()); + switch (data.currentVersion().getSelf().getType()) { case CURSEFORGE: source.set(i18n("mods.curseforge")); break; @@ -274,19 +276,19 @@ public void setSource(String source) { } } - public static class ModUpdateTask extends Task { + public static class UpdateTask extends Task { private final Collection> dependents; - private final List failedMods = new ArrayList<>(); + private final List failedMods = new ArrayList<>(); - ModUpdateTask(ModManager modManager, List> mods) { + UpdateTask(Path modDirectory, List> mods) { setStage("mods.check_updates.confirm"); getProperties().put("total", mods.size()); this.dependents = new ArrayList<>(); - for (Pair mod : mods) { - LocalModFile local = mod.getKey(); + for (Pair mod : mods) { + LocalAddonFile local = mod.getKey(); RemoteMod.Version remote = mod.getValue(); - boolean isDisabled = local.getModManager().isDisabled(local.getFile()); + boolean isDisabled = local.isDisabled(); dependents.add(Task .runAsync(Schedulers.javafx(), () -> local.setOld(true)) @@ -297,7 +299,7 @@ public static class ModUpdateTask extends Task { var task = new FileDownloadTask( remote.getFile().getUrl(), - modManager.getModsDirectory().resolve(fileName)); + modDirectory.resolve(fileName)); task.setName(remote.getName()); return task; @@ -307,15 +309,21 @@ public static class ModUpdateTask extends Task { // restore state if failed local.setOld(false); if (isDisabled) - local.disable(); + local.markDisabled(); failedMods.add(local); + } else if (!local.keepOldFiles()) { + try { + local.delete(); + } catch (IOException e) { + // ignore + } } }) .withCounter("mods.check_updates.confirm")); } } - public List getFailedMods() { + public List getFailedMods() { return failedMods; } diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java index 82261030f1..7b9ea397a3 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/ui/versions/VersionPage.java @@ -56,7 +56,7 @@ public class VersionPage extends DecoratorAnimatedPage implements DecoratorPage private final TabHeader.Tab modListTab = new TabHeader.Tab<>("modListTab"); private final TabHeader.Tab worldListTab = new TabHeader.Tab<>("worldList"); private final TabHeader.Tab schematicsTab = new TabHeader.Tab<>("schematicsTab"); - private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); + private final TabHeader.Tab resourcePackTab = new TabHeader.Tab<>("resourcePackTab"); private final TransitionPane transitionPane = new TransitionPane(); private final BooleanProperty currentVersionUpgradable = new SimpleBooleanProperty(); private final ObjectProperty version = new SimpleObjectProperty<>(); @@ -68,7 +68,7 @@ public VersionPage() { versionSettingsTab.setNodeSupplier(loadVersionFor(() -> new VersionSettingsPage(false))); installerListTab.setNodeSupplier(loadVersionFor(InstallerListPage::new)); modListTab.setNodeSupplier(loadVersionFor(ModListPage::new)); - resourcePackTab.setNodeSupplier(loadVersionFor(ResourcepackListPage::new)); + resourcePackTab.setNodeSupplier(loadVersionFor(ResourcePackListPage::new)); worldListTab.setNodeSupplier(loadVersionFor(WorldListPage::new)); schematicsTab.setNodeSupplier(loadVersionFor(SchematicsPage::new)); diff --git a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java index adb879e88b..24edd6326a 100644 --- a/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java +++ b/HMCL/src/main/java/org/jackhuang/hmcl/util/NativePatcher.java @@ -164,7 +164,7 @@ public static Version patchNative(DefaultGameRepository repository, if (lwjglVersionChanged) { ModManager modManager = repository.getModManager(version.getId()); try { - for (LocalModFile mod : modManager.getMods()) { + for (LocalModFile mod : modManager.getLocalFiles()) { if ("sodium".equals(mod.getId())) { // https://github.com/CaffeineMC/sodium/issues/2561 javaArguments.add("-Dsodium.checks.issue2561=false"); diff --git a/HMCL/src/main/resources/assets/css/root.css b/HMCL/src/main/resources/assets/css/root.css index 0a3222a934..e99981388b 100644 --- a/HMCL/src/main/resources/assets/css/root.css +++ b/HMCL/src/main/resources/assets/css/root.css @@ -1019,6 +1019,10 @@ -fx-background-color: -monet-error-container; } +.resource-pack-list-cell:warning { + -fx-background-color: -monet-error-container; +} + .options-sublist { -fx-background-color: -monet-surface; } diff --git a/HMCL/src/main/resources/assets/lang/I18N.properties b/HMCL/src/main/resources/assets/lang/I18N.properties index 7b2439aa8a..c617122afe 100644 --- a/HMCL/src/main/resources/assets/lang/I18N.properties +++ b/HMCL/src/main/resources/assets/lang/I18N.properties @@ -182,8 +182,10 @@ button.change_source=Change Download Source button.clear=Clear button.copy_and_exit=Copy and Exit button.delete=Delete +button.disable=Disable button.do_not_show_again=Don't show again button.edit=Edit +button.enable=Enable button.install=Install button.export=Export button.no=No @@ -349,6 +351,7 @@ download.hint=Install games and modpacks or download mods, resource packs, shade download.code.404=File "%s" not found on the remote server. download.content=Addons download.shader=Shaders +download.shader.title=Download Shader - %1s download.curseforge.unavailable=This HMCL build does not support access to CurseForge. Please use the official build to access CurseForge. download.existing=The file cannot be saved because it already exists. You can click "Save As" to save the file elsewhere. download.external_link=Visit Download Website @@ -1065,11 +1068,11 @@ mods.category=Category mods.channel.alpha=Alpha mods.channel.beta=Beta mods.channel.release=Release -mods.check_updates=Mod update process +mods.check_updates=File update process mods.check_updates.button=Update mods.check_updates.confirm=Update mods.check_updates.current_version=Current Version -mods.check_updates.empty=All mods are up-to-date +mods.check_updates.empty=All files are up-to-date mods.check_updates.failed_check=Failed to check for updates. mods.check_updates.failed_download=Failed to download some files. mods.check_updates.file=File @@ -1232,11 +1235,18 @@ repositories.chooser.title=Choose download source for JavaFX resourcepack=Resource Packs resourcepack.add=Add -resourcepack.manage=Resource Packs -resourcepack.download=Download -resourcepack.add.failed=Failed to add resource pack +resourcepack.add.failed=Failed to add resource packs: +resourcepack.compatible=Compatible resourcepack.delete.failed=Failed to delete resource pack +resourcepack.download=Download resourcepack.download.title=Download Resource Pack - %1s +resourcepack.manage=Resource Packs +resourcepack.warning.invalid=Invalid pack metadata +resourcepack.warning.manipulate=Resource pack activation could be influenced by mods, so the actual state may be different from what is set here.\n\nAre you sure to enable/disable the resource pack? +resourcepack.warning.missing_game_meta=Missing instance metadata +resourcepack.warning.missing_pack_meta=Missing pack metadata +resourcepack.warning.too_new=For newer game versions +resourcepack.warning.too_old=For older game versions reveal.in_file_manager=Reveal in File Manager diff --git a/HMCL/src/main/resources/assets/lang/I18N_ar.properties b/HMCL/src/main/resources/assets/lang/I18N_ar.properties index 0035318041..50caa7554c 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ar.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ar.properties @@ -161,8 +161,10 @@ button.change_source=تغيير مصدر التنزيل button.clear=مسح button.copy_and_exit=نسخ والخروج button.delete=حذف +button.disable=تعطيل button.do_not_show_again=لا تظهر مرة أخرى button.edit=تحرير +button.enable=تفعيل button.install=تثبيت button.export=تصدير button.no=لا diff --git a/HMCL/src/main/resources/assets/lang/I18N_es.properties b/HMCL/src/main/resources/assets/lang/I18N_es.properties index b61310854a..c70d9c4008 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_es.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_es.properties @@ -164,8 +164,10 @@ button.change_source=Cambiar fuente de descarga button.clear=Limpiar button.copy_and_exit=Copiar y salir button.delete=Borrar +button.disable=Desactivar button.do_not_show_again=No volver a mostrar button.edit=Editar +button.enable=Activar button.install=Instalar button.export=Exportar button.no=No diff --git a/HMCL/src/main/resources/assets/lang/I18N_ja.properties b/HMCL/src/main/resources/assets/lang/I18N_ja.properties index 13a36e60df..b8dd64021b 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ja.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ja.properties @@ -133,7 +133,9 @@ button.change_source=ダウンロードソースの変更 button.clear=クリア button.copy_and_exit=コピーして終了 button.delete=削除 +button.disable=無効にする button.edit=編集 +button.enable=有効にする button.install=インストール button.export=エクスポート button.no=いいえ diff --git a/HMCL/src/main/resources/assets/lang/I18N_ru.properties b/HMCL/src/main/resources/assets/lang/I18N_ru.properties index 6873232d75..bde13127c9 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_ru.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_ru.properties @@ -163,7 +163,9 @@ button.change_source=Изменить источник скачивания button.clear=Очистить button.copy_and_exit=Скопировать и выйти button.delete=Удалить +button.disable=Отключить button.edit=Изменить +button.enable=Включить button.install=Установить button.export=Экспорт button.no=Нет diff --git a/HMCL/src/main/resources/assets/lang/I18N_uk.properties b/HMCL/src/main/resources/assets/lang/I18N_uk.properties index 30949bc442..33d89eaf02 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_uk.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_uk.properties @@ -162,7 +162,9 @@ button.change_source=Змінити джерело завантаження button.clear=Очистити button.copy_and_exit=Копіювати та вийти button.delete=Видалити +button.disable=Вимкнути button.edit=Редагувати +button.enable=Увімкнути button.install=Встановити button.export=Експортувати button.no=Ні diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh.properties b/HMCL/src/main/resources/assets/lang/I18N_zh.properties index 623bcb8849..9f96d74de2 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh.properties @@ -177,9 +177,11 @@ button.change_source=切換下載源 button.clear=清除 button.copy_and_exit=複製並退出 button.delete=刪除 +button.disable=停用 button.do_not_show_again=不再顯示 button.edit=編輯 button.install=安裝 +button.enable=啟用 button.export=匯出 button.no=否 button.ok=確定 @@ -343,6 +345,7 @@ download.hint=安裝遊戲和模組包或下載模組、資源包、光影和世 download.code.404=遠端伺服器沒有需要下載的檔案:%s download.content=遊戲內容 download.shader=光影 +download.shader.title=光影下載 - %1s download.curseforge.unavailable=這個 HMCL 版本不支援訪問 CurseForge。請使用官方版本進行下載。 download.existing=檔案已存在,無法儲存。你可以將檔案儲存至其他地方。 download.external_link=開啟下載網站 @@ -853,11 +856,11 @@ mods.category=類別 mods.channel.alpha=Alpha mods.channel.beta=Beta mods.channel.release=Release -mods.check_updates=模組更新檢查 +mods.check_updates=檔案更新檢查 mods.check_updates.button=檢查更新 mods.check_updates.confirm=更新 mods.check_updates.current_version=目前版本 -mods.check_updates.empty=沒有需要更新的模組 +mods.check_updates.empty=沒有需要更新的檔案 mods.check_updates.failed_check=檢查更新失敗 mods.check_updates.failed_download=部分檔案下載失敗 mods.check_updates.file=檔案 @@ -1016,11 +1019,18 @@ repositories.chooser.title=選取 JavaFX 下載源 resourcepack=資源包 resourcepack.add=新增資源包 -resourcepack.manage=資源包管理 -resourcepack.download=下載資源包 -resourcepack.add.failed=新增資源包失敗 +resourcepack.add.failed=新增資源包失敗: +resourcepack.compatible=適用於此版本 resourcepack.delete.failed=刪除資源包失敗 +resourcepack.download=下載資源包 resourcepack.download.title=資源包下載 - %1s +resourcepack.manage=資源包管理 +resourcepack.warning.invalid=資源包中繼資料無效 +resourcepack.warning.manipulate=資源包的載入可能會受到模組干擾,實際效果可能與此處設定的不同。\n\n你確定要啟用或停用此資源包嗎? +resourcepack.warning.missing_game_meta=目前實例中繼資料缺失 +resourcepack.warning.missing_pack_meta=資源包中繼資料缺失 +resourcepack.warning.too_new=為更新的遊戲版本製作 +resourcepack.warning.too_old=為更老的遊戲版本製作 reveal.in_file_manager=在檔案管理員中查看 diff --git a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties index eac122af6b..a15aca0fdf 100644 --- a/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties +++ b/HMCL/src/main/resources/assets/lang/I18N_zh_CN.properties @@ -179,9 +179,11 @@ button.change_source=切换下载源 button.clear=清除 button.copy_and_exit=复制并退出 button.delete=删除 +button.disable=禁用 button.do_not_show_again=不再显示 button.edit=修改 button.install=安装 +button.enable=启用 button.export=导出 button.no=否 button.ok=确定 @@ -345,6 +347,7 @@ download.hint=安装游戏和整合包或下载模组、资源包、光影和世 download.code.404=远程服务器不包含需要下载的文件: %s\n你可以点击右上角帮助按钮进行求助。 download.content=游戏内容 download.shader=光影 +download.shader.title=光影下载 - %1s download.curseforge.unavailable=此 HMCL 版本不支持访问 CurseForge。请使用官方版本进行下载。 download.existing=文件已存在,无法保存。你可以将文件保存至其他地方。 download.external_link=打开下载网站 @@ -857,11 +860,11 @@ mods.category=类别 mods.channel.alpha=快照版本 mods.channel.beta=测试版本 mods.channel.release=稳定版本 -mods.check_updates=模组更新检查 +mods.check_updates=文件更新检查 mods.check_updates.button=检查更新 mods.check_updates.confirm=更新 mods.check_updates.current_version=当前版本 -mods.check_updates.empty=没有需要更新的模组 +mods.check_updates.empty=没有需要更新的文件 mods.check_updates.failed_check=检查更新失败 mods.check_updates.failed_download=部分文件下载失败 mods.check_updates.file=文件 @@ -1020,11 +1023,18 @@ repositories.chooser.title=选择 JavaFX 下载源 resourcepack=资源包 resourcepack.add=添加资源包 -resourcepack.manage=资源包管理 -resourcepack.download=下载资源包 -resourcepack.add.failed=添加资源包失败 +resourcepack.add.failed=添加资源包失败: +resourcepack.compatible=适用于此版本 resourcepack.delete.failed=删除资源包失败 +resourcepack.download=下载资源包 resourcepack.download.title=资源包下载 - %1s +resourcepack.manage=资源包管理 +resourcepack.warning.invalid=资源包元数据无效 +resourcepack.warning.manipulate=资源包的加载可能会受到模组干扰,实际效果可能与此处设置的不同。\n\n你确定要启用或禁用此资源包吗? +resourcepack.warning.missing_game_meta=当前实例元数据缺失 +resourcepack.warning.missing_pack_meta=资源包元数据缺失 +resourcepack.warning.too_new=为更新的游戏版本打造 +resourcepack.warning.too_old=为更老的游戏版本打造 reveal.in_file_manager=在文件管理器中查看 diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java index 42d5fcafec..3f2407a5ff 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/DefaultGameRepository.java @@ -154,6 +154,11 @@ public Path getModsDirectory(String id) { return getRunDirectory(id).resolve("mods"); } + @Override + public Path getResourcePackDirectory(String id) { + return getRunDirectory(id).resolve("resourcepacks"); + } + @Override public Path getVersionRoot(String id) { return getBaseDirectory().resolve("versions/" + id); @@ -564,8 +569,4 @@ public String toString() { .append("baseDirectory", baseDirectory) .toString(); } - - public Path getResourcepacksDirectory(String id) { - return getRunDirectory(id).resolve("resourcepacks"); - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java index e9049123a9..1b7cc27dcd 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/game/GameRepository.java @@ -137,6 +137,12 @@ default Task refreshVersionsAsync() { /// @return the mods directory Path getModsDirectory(String id); + /// Get the directory for placing resource packs. + /// + /// @param id instance id + /// @return the resource pack directory + Path getResourcePackDirectory(String id); + /** * Get minecraft jar * diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalAddonFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalAddonFile.java new file mode 100644 index 0000000000..8e611952cf --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalAddonFile.java @@ -0,0 +1,45 @@ +package org.jackhuang.hmcl.mod; + +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; + +/// Sub-classes should implement `Comparable` +public sealed abstract class LocalAddonFile permits LocalModFile, ResourcePackFile { + + private final boolean keepOldFiles; + + protected LocalAddonFile(boolean keepOldFiles) { + this.keepOldFiles = keepOldFiles; + } + + public abstract Path getFile(); + + /// Without extension + public abstract String getFileName(); + + public boolean isDisabled() { + return FileUtils.getName(getFile()).endsWith(LocalFileManager.DISABLED_EXTENSION); + } + + public abstract void markDisabled() throws IOException; + + public abstract void setOld(boolean old) throws IOException; + + public boolean keepOldFiles() { + return keepOldFiles; + } + + public abstract void delete() throws IOException; + + @Nullable + public abstract ModUpdate checkUpdates(String gameVersion, RemoteModRepository repository) throws IOException; + + public record ModUpdate(LocalAddonFile localFile, RemoteMod.Version currentVersion, + List candidates) { + } + +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalFileManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalFileManager.java new file mode 100644 index 0000000000..ffa8a956ea --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalFileManager.java @@ -0,0 +1,85 @@ +package org.jackhuang.hmcl.mod; + +import org.jackhuang.hmcl.game.GameRepository; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Unmodifiable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +public abstract class LocalFileManager { + + public static final String DISABLED_EXTENSION = ".disabled"; + public static final String OLD_EXTENSION = ".old"; + + public static String getLocalFileName(Path file) { + return StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION); + } + + protected final Set localFiles = new LinkedHashSet<>(); + + protected final GameRepository repository; + protected final String id; + + public LocalFileManager(GameRepository gameRepository, String versionId) { + this.repository = gameRepository; + this.id = versionId; + } + + public GameRepository getRepository() { + return repository; + } + + public String getInstanceId() { + return id; + } + + public abstract Path getDirectory(); + + public abstract void refresh() throws IOException; + + public @Unmodifiable List getLocalFiles() throws IOException { + return localFiles.stream().sorted().toList(); + } + + public Path setOld(T modFile, boolean old) throws IOException { + Path newPath; + if (old) { + newPath = backupFile(modFile.getFile()); + localFiles.remove(modFile); + } else { + newPath = restoreFile(modFile.getFile()); + localFiles.add(modFile); + } + return newPath; + } + + private Path backupFile(Path file) throws IOException { + Path newPath = file.resolveSibling( + StringUtils.addSuffix( + StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION), + OLD_EXTENSION + ) + ); + if (Files.exists(file)) { + Files.move(file, newPath, StandardCopyOption.REPLACE_EXISTING); + } + return newPath; + } + + private Path restoreFile(Path file) throws IOException { + Path newPath = file.resolveSibling( + StringUtils.removeSuffix(FileUtils.getName(file), OLD_EXTENSION) + ); + if (Files.exists(file)) { + Files.move(file, newPath, StandardCopyOption.REPLACE_EXISTING); + } + return newPath; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java index 5cb1a4403f..4b17d9fa86 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/LocalModFile.java @@ -22,6 +22,7 @@ import org.jackhuang.hmcl.util.io.FileUtils; import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.*; import java.util.stream.Collectors; @@ -32,7 +33,7 @@ * * @author huangyuhui */ -public final class LocalModFile implements Comparable { +public final class LocalModFile extends LocalAddonFile implements Comparable { private Path file; private final ModManager modManager; @@ -52,6 +53,7 @@ public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, } public LocalModFile(ModManager modManager, LocalMod mod, Path file, String name, Description description, String authors, String version, String gameVersion, String url, String logoPath) { + super(true); this.modManager = modManager; this.mod = mod; this.file = file; @@ -81,7 +83,7 @@ protected void invalidated() { } }; - fileName = FileUtils.getNameWithoutExtension(ModManager.getModName(file)); + fileName = FileUtils.getNameWithoutExtension(LocalFileManager.getLocalFileName(file)); if (isOld()) { mod.getOldFiles().add(this); @@ -98,6 +100,7 @@ public LocalMod getMod() { return mod; } + @Override public Path getFile() { return file; } @@ -150,6 +153,7 @@ public void setActive(boolean active) { activeProperty.set(active); } + @Override public String getFileName() { return fileName; } @@ -158,6 +162,7 @@ public boolean isOld() { return modManager.isOld(file); } + @Override public void setOld(boolean old) throws IOException { file = modManager.setOld(this, old); @@ -170,13 +175,20 @@ public void setOld(boolean old) throws IOException { } } - public void disable() throws IOException { + @Override + public void markDisabled() throws IOException { file = modManager.disableMod(file); } + @Override + public void delete() throws IOException { + Files.deleteIfExists(file); + } + + @Override public ModUpdate checkUpdates(String gameVersion, RemoteModRepository repository) throws IOException { - Optional currentVersion = repository.getRemoteVersionByLocalFile(this, file); - if (!currentVersion.isPresent()) return null; + Optional currentVersion = repository.getRemoteVersionByLocalFile(file); + if (currentVersion.isEmpty()) return null; List remoteVersions = repository.getRemoteVersionsById(currentVersion.get().getModid()) .filter(version -> version.getGameVersions().contains(gameVersion)) .filter(version -> version.getLoaders().contains(getModLoaderType())) @@ -202,30 +214,6 @@ public int hashCode() { return Objects.hash(getFileName()); } - public static class ModUpdate { - private final LocalModFile localModFile; - private final RemoteMod.Version currentVersion; - private final List candidates; - - public ModUpdate(LocalModFile localModFile, RemoteMod.Version currentVersion, List candidates) { - this.localModFile = localModFile; - this.currentVersion = currentVersion; - this.candidates = candidates; - } - - public LocalModFile getLocalMod() { - return localModFile; - } - - public RemoteMod.Version getCurrentVersion() { - return currentVersion; - } - - public List getCandidates() { - return candidates; - } - } - public static class Description { private final List parts; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModLoaderType.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModLoaderType.java index f4b8feacd8..641b15cec6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModLoaderType.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModLoaderType.java @@ -24,6 +24,5 @@ public enum ModLoaderType { NEO_FORGED, FABRIC, QUILT, - LITE_LOADER, - PACK; + LITE_LOADER } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java index ec25f1ed31..9884768bf4 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ModManager.java @@ -36,7 +36,7 @@ import static org.jackhuang.hmcl.util.Pair.pair; import static org.jackhuang.hmcl.util.logging.Logger.LOG; -public final class ModManager { +public final class ModManager extends LocalFileManager { @FunctionalInterface private interface ModMetadataReader { LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException; @@ -51,39 +51,26 @@ private interface ModMetadataReader { pair(ForgeNewModMetadata::fromNeoForgeFile, ModLoaderType.NEO_FORGED), pair(ForgeOldModMetadata::fromFile, ModLoaderType.FORGE), pair(FabricModMetadata::fromFile, ModLoaderType.FABRIC), - pair(QuiltModMetadata::fromFile, ModLoaderType.QUILT), - pair(PackMcMeta::fromFile, ModLoaderType.PACK) + pair(QuiltModMetadata::fromFile, ModLoaderType.QUILT) ); - map.put("zip", zipReaders); map.put("jar", zipReaders); map.put("litemod", List.of(pair(LiteModMetadata::fromFile, ModLoaderType.LITE_LOADER))); READERS = map; } - private final GameRepository repository; - private final String id; - private final TreeSet localModFiles = new TreeSet<>(); private final HashMap, LocalMod> localMods = new HashMap<>(); private LibraryAnalyzer analyzer; private boolean loaded = false; public ModManager(GameRepository repository, String id) { - this.repository = repository; - this.id = id; + super(repository, id); } - public GameRepository getRepository() { - return repository; - } - - public String getInstanceId() { - return id; - } - - public Path getModsDirectory() { + @Override + public Path getDirectory() { return repository.getModsDirectory(id); } @@ -167,18 +154,19 @@ private void addModInfo(Path file) { } if (!modInfo.isOld()) { - localModFiles.add(modInfo); + localFiles.add(modInfo); } } - public void refreshMods() throws IOException { - localModFiles.clear(); + @Override + public void refresh() throws IOException { + localFiles.clear(); localMods.clear(); analyzer = LibraryAnalyzer.analyze(getRepository().getResolvedPreservingPatchesVersion(id), null); - if (Files.isDirectory(getModsDirectory())) { - try (DirectoryStream modsDirectoryStream = Files.newDirectoryStream(getModsDirectory())) { + if (Files.isDirectory(getDirectory())) { + try (DirectoryStream modsDirectoryStream = Files.newDirectoryStream(getDirectory())) { for (Path subitem : modsDirectoryStream) { if (Files.isDirectory(subitem) && VersionNumber.isIntVersionNumber(FileUtils.getName(subitem))) { // If the folder name is game version, forge will search mod in this subdirectory @@ -196,10 +184,10 @@ public void refreshMods() throws IOException { loaded = true; } - public @Unmodifiable List getMods() throws IOException { + public @Unmodifiable List getLocalFiles() throws IOException { if (!loaded) - refreshMods(); - return List.copyOf(localModFiles); + refresh(); + return super.getLocalFiles(); } public void addMod(Path file) throws IOException { @@ -207,9 +195,9 @@ public void addMod(Path file) throws IOException { throw new IllegalArgumentException("File " + file + " is not a valid mod file."); if (!loaded) - refreshMods(); + refresh(); - Path modsDirectory = getModsDirectory(); + Path modsDirectory = getDirectory(); Files.createDirectories(modsDirectory); Path newFile = modsDirectory.resolve(file.getFileName()); @@ -220,7 +208,7 @@ public void addMod(Path file) throws IOException { public void removeMods(LocalModFile... localModFiles) throws IOException { for (LocalModFile localModFile : localModFiles) { - Files.deleteIfExists(localModFile.getFile()); + localModFile.delete(); } } @@ -228,7 +216,7 @@ public void rollback(LocalModFile from, LocalModFile to) throws IOException { if (!loaded) { throw new IllegalStateException("ModManager Not loaded"); } - if (!localModFiles.contains(from)) { + if (!localFiles.contains(from)) { throw new IllegalStateException("Rolling back an unknown mod " + from.getFileName()); } if (from.isOld()) { @@ -258,41 +246,6 @@ public void rollback(LocalModFile from, LocalModFile to) throws IOException { to.setActive(active); } - private Path backupMod(Path file) throws IOException { - Path newPath = file.resolveSibling( - StringUtils.addSuffix( - StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION), - OLD_EXTENSION - ) - ); - if (Files.exists(file)) { - Files.move(file, newPath, StandardCopyOption.REPLACE_EXISTING); - } - return newPath; - } - - private Path restoreMod(Path file) throws IOException { - Path newPath = file.resolveSibling( - StringUtils.removeSuffix(FileUtils.getName(file), OLD_EXTENSION) - ); - if (Files.exists(file)) { - Files.move(file, newPath, StandardCopyOption.REPLACE_EXISTING); - } - return newPath; - } - - public Path setOld(LocalModFile modFile, boolean old) throws IOException { - Path newPath; - if (old) { - newPath = backupMod(modFile.getFile()); - localModFiles.remove(modFile); - } else { - newPath = restoreMod(modFile.getFile()); - localModFiles.add(modFile); - } - return newPath; - } - public Path disableMod(Path file) throws IOException { if (isOld(file)) return file; // no need to disable an old mod. @@ -313,10 +266,6 @@ public Path enableMod(Path file) throws IOException { return enabled; } - public static String getModName(Path file) { - return StringUtils.removeSuffix(FileUtils.getName(file), DISABLED_EXTENSION, OLD_EXTENSION); - } - public boolean isOld(Path file) { return FileUtils.getName(file).endsWith(OLD_EXTENSION); } @@ -326,7 +275,7 @@ public boolean isDisabled(Path file) { } public static boolean isFileNameMod(Path file) { - String name = getModName(file); + String name = getLocalFileName(file); return name.endsWith(".zip") || name.endsWith(".jar") || name.endsWith(".litemod"); } @@ -370,12 +319,12 @@ public static boolean isFileMod(Path modFile) { * @return true if the file exists */ public boolean hasSimpleMod(String fileName) { - return Files.exists(getModsDirectory().resolve(StringUtils.removeSuffix(fileName, DISABLED_EXTENSION))) - || Files.exists(getModsDirectory().resolve(StringUtils.addSuffix(fileName, DISABLED_EXTENSION))); + return Files.exists(getDirectory().resolve(StringUtils.removeSuffix(fileName, DISABLED_EXTENSION))) + || Files.exists(getDirectory().resolve(StringUtils.addSuffix(fileName, DISABLED_EXTENSION))); } public Path getSimpleModPath(String fileName) { - return getModsDirectory().resolve(fileName); + return getDirectory().resolve(fileName); } public static final String DISABLED_EXTENSION = ".disabled"; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteMod.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteMod.java index a936f887be..35b846804b 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteMod.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteMod.java @@ -20,6 +20,7 @@ import org.jackhuang.hmcl.mod.curse.CurseForgeRemoteModRepository; import org.jackhuang.hmcl.mod.modrinth.ModrinthRemoteModRepository; import org.jackhuang.hmcl.task.FileDownloadTask; +import org.jetbrains.annotations.Nullable; import java.io.IOException; import java.time.Instant; @@ -40,7 +41,7 @@ public List loadDependencies(RemoteModRepository modRepository) throw public Stream loadVersions(RemoteModRepository modRepository) throws IOException { throw new IOException(); } - }); + }, RemoteModRepository.Type.MOD); private final String slug; private final String author; @@ -50,8 +51,9 @@ public Stream loadVersions(RemoteModRepository modRepository) private final String pageUrl; private final String iconUrl; private final IMod data; + private final RemoteModRepository.Type repoType; - public RemoteMod(String slug, String author, String title, String description, List categories, String pageUrl, String iconUrl, IMod data) { + public RemoteMod(String slug, String author, String title, String description, List categories, String pageUrl, String iconUrl, IMod data, RemoteModRepository.Type repoType) { this.slug = slug; this.author = author; this.title = title; @@ -60,6 +62,7 @@ public RemoteMod(String slug, String author, String title, String description, L this.pageUrl = pageUrl; this.iconUrl = iconUrl; this.data = data; + this.repoType = repoType; } public String getSlug() { @@ -94,6 +97,10 @@ public IMod getData() { return data; } + public RemoteModRepository.Type getRepositoryType() { + return repoType; + } + public enum VersionType { Release, Beta, @@ -187,17 +194,56 @@ public int hashCode() { } public enum Type { - CURSEFORGE(CurseForgeRemoteModRepository.MODS), - MODRINTH(ModrinthRemoteModRepository.MODS); - - private final RemoteModRepository remoteModRepository; - - public RemoteModRepository getRemoteModRepository() { - return this.remoteModRepository; + CURSEFORGE( + CurseForgeRemoteModRepository.MODS, + CurseForgeRemoteModRepository.RESOURCE_PACKS, + null, + CurseForgeRemoteModRepository.WORLDS, + CurseForgeRemoteModRepository.MODPACKS, + CurseForgeRemoteModRepository.CUSTOMIZATIONS + ), + MODRINTH( + ModrinthRemoteModRepository.MODS, + ModrinthRemoteModRepository.RESOURCE_PACKS, + ModrinthRemoteModRepository.SHADER_PACKS, + null, + ModrinthRemoteModRepository.MODPACKS, + null + ); + + public final RemoteModRepository modRepo; + public final RemoteModRepository resourcePackRepo; + public final RemoteModRepository shaderPackRepo; + public final RemoteModRepository worldRepo; + public final RemoteModRepository modpackRepo; + public final RemoteModRepository customizationRepo; + + @Nullable + public RemoteModRepository getRepoForType(RemoteModRepository.Type type) { + return switch (type) { + case MOD -> modRepo; + case RESOURCE_PACK -> resourcePackRepo; + case SHADER_PACK -> shaderPackRepo; + case WORLD -> worldRepo; + case MODPACK -> modpackRepo; + case CUSTOMIZATION -> customizationRepo; + }; } - Type(RemoteModRepository remoteModRepository) { - this.remoteModRepository = remoteModRepository; + Type( + RemoteModRepository modRepo, + RemoteModRepository resourcePackRepo, + RemoteModRepository shaderPackRepo, + RemoteModRepository worldRepo, + RemoteModRepository modpackRepo, + RemoteModRepository customizationRepo + ) { + this.modRepo = modRepo; + this.resourcePackRepo = resourcePackRepo; + this.shaderPackRepo = shaderPackRepo; + this.worldRepo = worldRepo; + this.modpackRepo = modpackRepo; + this.customizationRepo = customizationRepo; } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java index 4dcd467fe6..964baf3e90 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/RemoteModRepository.java @@ -88,7 +88,7 @@ public int getTotalPages() { SearchResult search(DownloadProvider downloadProvider, String gameVersion, @Nullable Category category, int pageOffset, int pageSize, String searchFilter, SortType sortType, SortOrder sortOrder) throws IOException; - Optional getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException; + Optional getRemoteVersionByLocalFile(Path file) throws IOException; RemoteMod getModById(String id) throws IOException; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackFile.java new file mode 100644 index 0000000000..a3f366a375 --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackFile.java @@ -0,0 +1,109 @@ +package org.jackhuang.hmcl.mod; + +import org.jackhuang.hmcl.mod.modinfo.PackMcMeta; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Locale; + +public sealed abstract class ResourcePackFile extends LocalAddonFile implements Comparable permits ResourcePackFolder, ResourcePackZipFile { + static ResourcePackFile parse(ResourcePackManager manager, Path path) throws IOException { + if (isFileResourcePack(path)) { + return Files.isRegularFile(path) ? new ResourcePackZipFile(manager, path) : new ResourcePackFolder(manager, path); + } + return null; + } + + public static boolean isFileResourcePack(Path file) { + return Files.exists(file) && (file.toString().toLowerCase(Locale.ROOT).endsWith(".zip") || Files.isRegularFile(file.resolve("pack.mcmeta"))); + } + + protected final ResourcePackManager manager; + protected Path file; + protected final String fileName; + protected final String fileNameWithExtension; + + private Compatibility compatibility = null; + + protected ResourcePackFile(ResourcePackManager manager, Path file) { + super(false); + this.manager = manager; + this.file = file; + this.fileName = StringUtils.parseColorEscapes(FileUtils.getNameWithoutExtension(file)); + this.fileNameWithExtension = file.getFileName().toString(); + } + + @Override + public Path getFile() { + return file; + } + + @Override + public String getFileName() { + return fileName; + } + + public String getFileNameWithExtension() { + return fileNameWithExtension; + } + + public Compatibility getCompatibility() { + if (compatibility == null) { + compatibility = manager.getCompatibility(this); + } + return compatibility; + } + + public boolean isEnabled() { + return manager.isEnabled(this); + } + + public void setEnabled(boolean enabled) { + if (enabled) { + manager.enableResourcePack(this); + } else { + manager.disableResourcePack(this); + } + } + + @Override + public void setOld(boolean old) throws IOException { + this.file = manager.setOld(this, old); + } + + @Override + public void markDisabled() { + } + + @Nullable + @Contract(pure = true) + public abstract PackMcMeta getMeta(); + + @Nullable + public LocalModFile.Description getDescription() { + if (getMeta() == null || getMeta().pack() == null) return null; + return getMeta().pack().description(); + } + + public abstract byte @Nullable [] getIcon(); + + @Override + public int compareTo(@NotNull ResourcePackFile other) { + return this.fileNameWithExtension.compareTo(other.fileNameWithExtension); + } + + public enum Compatibility { + COMPATIBLE, + TOO_NEW, + TOO_OLD, + INVALID, + MISSING_PACK_META, + MISSING_GAME_META + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackFolder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackFolder.java new file mode 100644 index 0000000000..81026e3b6d --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackFolder.java @@ -0,0 +1,58 @@ +package org.jackhuang.hmcl.mod; + +import org.jackhuang.hmcl.mod.modinfo.PackMcMeta; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +final class ResourcePackFolder extends ResourcePackFile { + private final PackMcMeta meta; + private final byte @Nullable [] icon; + + public ResourcePackFolder(ResourcePackManager manager, Path path) { + super(manager, path); + + PackMcMeta meta = null; + try { + meta = JsonUtils.fromJsonFile(path.resolve("pack.mcmeta"), PackMcMeta.class); + } catch (Exception e) { + LOG.warning("Failed to parse resource pack meta", e); + } + this.meta = meta; + + byte[] icon; + try { + icon = Files.readAllBytes(path.resolve("pack.png")); + } catch (IOException e) { + icon = null; + LOG.warning("Failed to read resource pack icon", e); + } + this.icon = icon; + } + + @Override + public PackMcMeta getMeta() { + return meta; + } + + @Override + public byte @Nullable [] getIcon() { + return icon; + } + + @Override + public void delete() throws IOException { + FileUtils.deleteDirectory(file); + } + + @Override + public ModUpdate checkUpdates(String gameVersion, RemoteModRepository repository) { + return null; + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackManager.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackManager.java new file mode 100644 index 0000000000..5cef1fba6d --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackManager.java @@ -0,0 +1,377 @@ +/* + * Hello Minecraft! Launcher + * Copyright (C) 2020 huangyuhui and contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.jackhuang.hmcl.mod; + +import com.google.gson.annotations.SerializedName; +import org.jackhuang.hmcl.game.GameRepository; +import org.jackhuang.hmcl.mod.modinfo.PackMcMeta; +import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.gson.JsonSerializable; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jackhuang.hmcl.util.io.FileUtils; +import org.jackhuang.hmcl.util.tree.ZipFileTree; +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.jackhuang.hmcl.util.versioning.VersionRange; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Unmodifiable; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +public final class ResourcePackManager extends LocalFileManager { + + private static final List RESOURCE_PACK_VERSION_OLD = List.of( + "13w24a", // 1 + "15w31a", // 2 + "16w32a", // 3 + "17w48a", // 4 + "18w47b" // 5 + ); + + public static final String LEAST_MC_VERSION_RELEASE = "1.6.1"; + public static final String LEAST_MC_VERSION_SNAPSHOT = "13w24a"; + + public static boolean isMcVersionSupported(@NotNull GameVersionNumber version) { + return version.isAtLeast(ResourcePackManager.LEAST_MC_VERSION_RELEASE, ResourcePackManager.LEAST_MC_VERSION_SNAPSHOT); + } + + @NotNull + public static PackMcMeta.PackVersion getPackVersion(GameVersionNumber minecraftVersion, Path gameJar) { + for (int i = 0; i < RESOURCE_PACK_VERSION_OLD.size(); i++) { + if (minecraftVersion.compareTo(RESOURCE_PACK_VERSION_OLD.get(i)) < 0) { + return i == 0 ? PackMcMeta.PackVersion.UNSPECIFIED : new PackMcMeta.PackVersion(i, 0); + } + } + try (var zipFileTree = new ZipFileTree(CompressingUtils.openZipFile(gameJar))) { + String versionJson = zipFileTree.readTextEntry("/version.json"); + try { + var info = JsonUtils.fromNonNullJson(versionJson, GameVersionInfo117.class).packVersionInfo(); + if (info.resourceMajor() > 64) { + return new PackMcMeta.PackVersion(info.resourceMajor(), info.resourceMinor()); + } else { + return new PackMcMeta.PackVersion(info.resource(), 0); + } + } catch (Exception e) { + LOG.warning("Failed to load Minecraft resource pack version for 25w31a+", e); + } + try { + return new PackMcMeta.PackVersion(JsonUtils.fromNonNullJson(versionJson, GameVersionInfo114.class).packVersion, 0); + } catch (Exception e) { + LOG.warning("Failed to load Minecraft resource pack version for 18w47b+", e); + } + return PackMcMeta.PackVersion.UNSPECIFIED; + } catch (Exception e) { + LOG.error("Failed to load Minecraft resource pack version", e); + return PackMcMeta.PackVersion.UNSPECIFIED; + } + } + + @NotNull + @Contract(pure = true) + public static VersionRange getResourcePackVersionRangeOld(PackMcMeta.PackInfo packInfo) { + if (packInfo == null) { + return VersionRange.empty(); + } + boolean supportedFormatsUnspecified = packInfo.supportedFormats().isUnspecified(); + if (supportedFormatsUnspecified && packInfo.packFormat() <= 0) { + return VersionRange.empty(); + } + if (supportedFormatsUnspecified) { + return VersionRange.is(new PackMcMeta.PackVersion(packInfo.packFormat(), 0)); + } + return VersionRange.between(packInfo.supportedFormats().getMin(), packInfo.supportedFormats().getMax()); + } + + @NotNull + @Contract(pure = true) + public static VersionRange getResourcePackVersionRangeNew(PackMcMeta.PackInfo packInfo) { + if (packInfo == null) { + return VersionRange.empty(); + } + boolean packFormatUnspecified = packInfo.packFormat() <= 0; + boolean supportedFormatsUnspecified = packInfo.supportedFormats().isUnspecified(); + + // See https://zh.minecraft.wiki/w/Pack.mcmeta + // Also referring to Minecraft's source code + if (!(packInfo.minPackVersion().isUnspecified() || packInfo.maxPackVersion().isUnspecified())) { + int minMajor = packInfo.minPackVersion().majorVersion(); + int maxMajor = packInfo.maxPackVersion().majorVersion(); + if (packInfo.minPackVersion().compareTo(packInfo.maxPackVersion()) > 0) { + return VersionRange.empty(); + } + if (minMajor > 64) { + if (!supportedFormatsUnspecified) { + return VersionRange.empty(); + } + + if (!packFormatUnspecified && isPackFormatInvalid(minMajor, maxMajor, packInfo.packFormat())) { + return VersionRange.empty(); + } + } else { + if (supportedFormatsUnspecified) { + return VersionRange.empty(); + } + PackMcMeta.SupportedFormats supportedFormats = packInfo.supportedFormats(); + if (supportedFormats.min() != minMajor) { + return VersionRange.empty(); + } + if (supportedFormats.max() != maxMajor && supportedFormats.max() != 64) { + return VersionRange.empty(); + } + if (packFormatUnspecified) return VersionRange.empty(); + if (isPackFormatInvalid(minMajor, maxMajor, packInfo.packFormat())) return VersionRange.empty(); + } + + return VersionRange.between(packInfo.minPackVersion(), packInfo.maxPackVersion()); + } else if (!supportedFormatsUnspecified) { + PackMcMeta.SupportedFormats supportedFormats = packInfo.supportedFormats(); + int min = supportedFormats.min(); + int max = supportedFormats.max(); + if (max > 64) { + return VersionRange.empty(); + } else { + if (packFormatUnspecified) return VersionRange.empty(); + if (isPackFormatInvalid(min, max, packInfo.packFormat())) return VersionRange.empty(); + } + + return VersionRange.between(supportedFormats.getMin(), supportedFormats.getMax()); + } else if (!packFormatUnspecified) { + int packFormat = packInfo.packFormat(); + PackMcMeta.PackVersion packVersion = new PackMcMeta.PackVersion(packFormat, 0); + return packFormat > 64 ? VersionRange.empty() : VersionRange.is(packVersion); + } + return VersionRange.empty(); + } + + @Contract(pure = true) + private static boolean isPackFormatInvalid(int i, int j, int k) { + if (k >= i && k <= j) { + return k < 15; + } else { + return true; + } + } + + private final GameVersionNumber minecraftVersion; + + private final Path resourcePackDirectory; + + private final Path optionsFile; + private final @NotNull PackMcMeta.PackVersion requiredVersion; + + private boolean loaded = false; + + public ResourcePackManager(GameRepository repository, String id) { + super(repository, id); + this.minecraftVersion = GameVersionNumber.asGameVersion(repository.getGameVersion(id)); + this.resourcePackDirectory = this.repository.getResourcePackDirectory(this.id); + this.optionsFile = repository.getRunDirectory(id).resolve("options.txt"); + this.requiredVersion = getPackVersion(minecraftVersion, repository.getVersionJar(id)); + + } + + @NotNull + private Map loadOptions() { + Map options = new LinkedHashMap<>(); + if (!Files.isRegularFile(optionsFile)) return options; + try (var stream = Files.lines(optionsFile)) { + stream.forEach(s -> { + if (StringUtils.isNotBlank(s)) { + var entry = s.split(":", 2); + if (entry.length == 2) { + options.put(entry[0], entry[1]); + } + } + }); + } catch (IOException e) { + LOG.warning("Failed to read instance options file", e); + } + return options; + } + + private void saveOptions(@NotNull Map options) { + try { + if (!Files.isRegularFile(optionsFile)) { + Files.createFile(optionsFile); + } + StringBuilder sb = new StringBuilder(); + for (var entry : options.entrySet()) { + sb.append(entry.getKey()).append(":").append(entry.getValue()).append(System.lineSeparator()); + } + Files.writeString(optionsFile, sb.toString(), StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING); + } catch (IOException e) { + LOG.warning("Failed to save instance options file", e); + } + } + + public GameVersionNumber getMinecraftVersion() { + return minecraftVersion; + } + + @Override + public Path getDirectory() { + return resourcePackDirectory; + } + + private void addResourcePackInfo(Path file) throws IOException { + ResourcePackFile resourcePack = ResourcePackFile.parse(this, file); + if (resourcePack != null) localFiles.add(resourcePack); + } + + @Override + public void refresh() throws IOException { + localFiles.clear(); + + if (Files.isDirectory(resourcePackDirectory)) { + try (DirectoryStream directoryStream = Files.newDirectoryStream(resourcePackDirectory)) { + for (Path subitem : directoryStream) { + addResourcePackInfo(subitem); + } + } + } + loaded = true; + } + + @Override + public @Unmodifiable List getLocalFiles() throws IOException { + if (!loaded) + refresh(); + return super.getLocalFiles(); + } + + public void importResourcePack(Path file) throws IOException, IllegalArgumentException { + if (ResourcePackFile.isFileResourcePack(file)) { + if (!loaded) + refresh(); + Files.createDirectories(resourcePackDirectory); + + Path newFile = resourcePackDirectory.resolve(file.getFileName()); + if (Files.isDirectory(file)) { + FileUtils.copyDirectory(file, newFile); + } else { + FileUtils.copyFile(file, newFile); + } + + addResourcePackInfo(newFile); + } else { + throw new IllegalArgumentException("File '" + file + "' is not a resource pack"); + } + + } + + public boolean removeResourcePacks(Iterable resourcePacks) throws IOException { + boolean modified = false; + for (ResourcePackFile resourcePack : resourcePacks) { + if (resourcePack != null && resourcePack.manager == this) { + resourcePack.delete(); + localFiles.remove(resourcePack); + modified = true; + } + } + return modified; + } + + public void enableResourcePack(ResourcePackFile resourcePack) { + if (resourcePack.manager != this) return; + Map options = loadOptions(); + String packId = "file/" + resourcePack.getFileNameWithExtension(); + boolean modified = false; + List resourcePacks = new ArrayList<>(StringUtils.deserializeStringList(options.get("resourcePacks"))); + if (!resourcePacks.contains(packId)) { + resourcePacks.add(packId); + options.put("resourcePacks", StringUtils.serializeStringList(resourcePacks)); + modified = true; + } + List incompatibleResourcePacks = new ArrayList<>(StringUtils.deserializeStringList(options.get("incompatibleResourcePacks"))); + if (!incompatibleResourcePacks.contains(packId) && isIncompatible(resourcePack)) { + incompatibleResourcePacks.add(packId); + options.put("incompatibleResourcePacks", StringUtils.serializeStringList(incompatibleResourcePacks)); + modified = true; + } + if (modified) saveOptions(options); + } + + public void disableResourcePack(ResourcePackFile resourcePack) { + if (resourcePack.manager != this) return; + Map options = loadOptions(); + String packId = "file/" + resourcePack.getFileNameWithExtension(); + boolean modified = false; + List resourcePacks = new ArrayList<>(StringUtils.deserializeStringList(options.get("resourcePacks"))); + if (resourcePacks.contains(packId)) { + resourcePacks.remove(packId); + options.put("resourcePacks", StringUtils.serializeStringList(resourcePacks)); + modified = true; + } + List incompatibleResourcePacks = new ArrayList<>(StringUtils.deserializeStringList(options.get("incompatibleResourcePacks"))); + if (incompatibleResourcePacks.contains(packId)) { + incompatibleResourcePacks.remove(packId); + options.put("incompatibleResourcePacks", StringUtils.serializeStringList(incompatibleResourcePacks)); + modified = true; + } + if (modified) saveOptions(options); + } + + public boolean isEnabled(ResourcePackFile resourcePack) { + if (resourcePack.manager != this) return false; + Map options = loadOptions(); + String packId = "file/" + resourcePack.getFileNameWithExtension(); + List resourcePacks = StringUtils.deserializeStringList(options.get("resourcePacks")); + if (!resourcePacks.contains(packId)) return false; + List incompatibleResourcePacks = StringUtils.deserializeStringList(options.get("incompatibleResourcePacks")); + return isIncompatible(resourcePack) == incompatibleResourcePacks.contains(packId); + } + + public ResourcePackFile.Compatibility getCompatibility(@NotNull ResourcePackFile resourcePack) { + if (resourcePack.getMeta() == null || resourcePack.getMeta().pack() == null) return ResourcePackFile.Compatibility.MISSING_PACK_META; + if (this.requiredVersion.isUnspecified()) return ResourcePackFile.Compatibility.MISSING_GAME_META; + var versionRange = requiredVersion.majorVersion() > 64 + ? getResourcePackVersionRangeNew(resourcePack.getMeta().pack()) + : getResourcePackVersionRangeOld(resourcePack.getMeta().pack()); + if (versionRange.isEmpty()) + return ResourcePackFile.Compatibility.INVALID; + if (versionRange.getMaximum().compareTo(this.requiredVersion) < 0) + return ResourcePackFile.Compatibility.TOO_OLD; + if (versionRange.getMinimum().compareTo(this.requiredVersion) > 0) + return ResourcePackFile.Compatibility.TOO_NEW; + return ResourcePackFile.Compatibility.COMPATIBLE; + } + + public boolean isIncompatible(@NotNull ResourcePackFile resourcePack) { + return getCompatibility(resourcePack) != ResourcePackFile.Compatibility.COMPATIBLE; + } + + @JsonSerializable + private record GameVersionInfo114(@SerializedName("pack_version") int packVersion) { + } + + @JsonSerializable + private record GameVersionInfo117(@SerializedName("pack_version") PackVersionInfo117 packVersionInfo) { + } + + @JsonSerializable + private record PackVersionInfo117(int resource, + @SerializedName("resource_major") int resourceMajor, + @SerializedName("resource_minor") int resourceMinor) { + } +} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackZipFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackZipFile.java new file mode 100644 index 0000000000..ddb1ee5fcf --- /dev/null +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/ResourcePackZipFile.java @@ -0,0 +1,78 @@ +package org.jackhuang.hmcl.mod; + +import org.jackhuang.hmcl.mod.modinfo.PackMcMeta; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jackhuang.hmcl.util.io.CompressingUtils; +import org.jackhuang.hmcl.util.tree.ZipFileTree; +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.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +import static org.jackhuang.hmcl.util.logging.Logger.LOG; + +final class ResourcePackZipFile extends ResourcePackFile { + private final PackMcMeta meta; + private final byte @Nullable [] icon; + + public ResourcePackZipFile(ResourcePackManager manager, Path path) throws IOException { + super(manager, path); + + PackMcMeta metaTemp = null; + byte[] iconTemp = null; + + try (var zipFileTree = new ZipFileTree(CompressingUtils.openZipFile(path))) { + try { + metaTemp = JsonUtils.fromNonNullJson(zipFileTree.readTextEntry("/pack.mcmeta"), PackMcMeta.class); + } catch (Exception e) { + LOG.warning("Failed to parse resource pack meta", e); + } + + var iconEntry = zipFileTree.getEntry("/pack.png"); + if (iconEntry != null) { + try (InputStream is = zipFileTree.getInputStream(iconEntry)) { + iconTemp = is.readAllBytes(); + } catch (Exception e) { + LOG.warning("Failed to load resource pack icon", e); + } + } + } + this.meta = metaTemp; + this.icon = iconTemp; + } + + @Override + public PackMcMeta getMeta() { + return meta; + } + + @Override + public byte @Nullable [] getIcon() { + return icon; + } + + @Override + public void delete() throws IOException { + Files.deleteIfExists(file); + } + + @Override + public ModUpdate checkUpdates(String gameVersion, RemoteModRepository repository) throws IOException { + Optional currentVersion = repository.getRemoteVersionByLocalFile(file); + if (currentVersion.isEmpty()) return null; + List remoteVersions = repository.getRemoteVersionsById(currentVersion.get().getModid()) + .filter(version -> version.getGameVersions().contains(gameVersion)) + .filter(version -> version.getDatePublished().compareTo(currentVersion.get().getDatePublished()) > 0) + .sorted(Comparator.comparing(RemoteMod.Version::getDatePublished).reversed()) + .collect(Collectors.toList()); + if (remoteVersions.isEmpty()) return null; + return new ModUpdate(this, currentVersion.get(), remoteVersions); + } +} + diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseAddon.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseAddon.java index e77fb259e1..6d6c44ebfe 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseAddon.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseAddon.java @@ -209,7 +209,7 @@ public Stream loadVersions(RemoteModRepository modRepository) return modRepository.getRemoteVersionsById(Integer.toString(id)); } - public RemoteMod toMod() { + public RemoteMod toMod(RemoteModRepository.Type type) { String iconUrl = Optional.ofNullable(logo).map(Logo::getThumbnailUrl).orElse(""); return new RemoteMod( @@ -220,7 +220,8 @@ public RemoteMod toMod() { categories.stream().map(category -> Integer.toString(category.getId())).collect(Collectors.toList()), links.websiteUrl, iconUrl, - this + this, + type ); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java index 71689ce051..447b85a294 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/curse/CurseForgeRemoteModRepository.java @@ -19,7 +19,6 @@ import com.google.gson.reflect.TypeToken; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.mod.RemoteModRepository; import org.jackhuang.hmcl.util.MurmurHash2; @@ -35,14 +34,7 @@ import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; -import java.util.ArrayList; -import java.util.Collections; -import java.util.Comparator; -import java.util.HashMap; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Optional; +import java.util.*; import java.util.concurrent.Semaphore; import java.util.stream.Stream; @@ -137,7 +129,7 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion pair("pageSize", Integer.toString(pageSize))))))) .getJson(Response.typeOf(listTypeOf(CurseAddon.class))); if (searchFilter.isEmpty()) { - return new SearchResult(response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize)); + return new SearchResult(response.getData().stream().map(addon -> addon.toMod(type)), calculateTotalPages(response, pageSize)); } // https://github.com/HMCL-dev/HMCL/issues/1549 @@ -149,7 +141,7 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion StringUtils.LevCalculator levCalculator = new StringUtils.LevCalculator(); - return new SearchResult(response.getData().stream().map(CurseAddon::toMod).map(remoteMod -> { + return new SearchResult(response.getData().stream().map(addon -> addon.toMod(type)).map(remoteMod -> { String lowerCaseResult = remoteMod.getTitle().toLowerCase(Locale.ROOT); int diff = levCalculator.calc(lowerCaseSearchFilter, lowerCaseResult); @@ -160,14 +152,14 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion } return pair(remoteMod, diff); - }).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.getData().stream().map(CurseAddon::toMod), calculateTotalPages(response, pageSize)); + }).sorted(Comparator.comparingInt(Pair::getValue)).map(Pair::getKey), response.getData().stream().map(addon -> addon.toMod(type)), calculateTotalPages(response, pageSize)); } finally { SEMAPHORE.release(); } } @Override - public Optional getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException { + public Optional getRemoteVersionByLocalFile(Path file) throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); try (InputStream stream = Files.newInputStream(file)) { byte[] buf = new byte[1024]; @@ -209,7 +201,7 @@ public RemoteMod getModById(String id) throws IOException { try { Response response = withApiKey(HttpRequest.GET(PREFIX + "/v1/mods/" + id)) .getJson(Response.typeOf(CurseAddon.class)); - return response.data.toMod(); + return response.data.toMod(type); } finally { SEMAPHORE.release(); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java index 024a0102ee..139daeeeac 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modinfo/PackMcMeta.java @@ -20,21 +20,14 @@ import com.google.gson.*; import com.google.gson.annotations.JsonAdapter; import com.google.gson.annotations.SerializedName; -import kala.compress.archivers.zip.ZipArchiveEntry; import org.jackhuang.hmcl.mod.LocalModFile; -import org.jackhuang.hmcl.mod.ModLoaderType; -import org.jackhuang.hmcl.mod.ModManager; import org.jackhuang.hmcl.util.Pair; import org.jackhuang.hmcl.util.StringUtils; import org.jackhuang.hmcl.util.gson.JsonSerializable; -import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.gson.Validation; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.tree.ZipFileTree; +import org.jetbrains.annotations.NotNull; -import java.io.IOException; import java.lang.reflect.Type; -import java.nio.file.Path; import java.util.ArrayList; import java.util.List; @@ -50,6 +43,7 @@ public void validate() throws JsonParseException { @JsonAdapter(PackInfoDeserializer.class) public record PackInfo(@SerializedName("pack_format") int packFormat, + @SerializedName("supported_formats") SupportedFormats supportedFormats, @SerializedName("min_format") PackVersion minPackVersion, @SerializedName("max_format") PackVersion maxPackVersion, @SerializedName("description") LocalModFile.Description description) { @@ -62,12 +56,51 @@ public PackVersion getEffectiveMaxVersion() { } } + public record SupportedFormats(int min, int max) { + + public static final SupportedFormats UNSPECIFIED = new SupportedFormats(-1, -1); + + public static SupportedFormats fromJson(JsonElement element) { + if (element == null || element.isJsonNull()) { + return UNSPECIFIED; + } + + try { + if (element instanceof JsonArray jsonArray) { + if (jsonArray.size() == 2 && jsonArray.get(0) instanceof JsonPrimitive && jsonArray.get(1) instanceof JsonPrimitive) { + return new SupportedFormats(jsonArray.get(0).getAsInt(), jsonArray.get(1).getAsInt()); + } else { + LOG.warning("Supported formats array must have 2 elements, but got " + jsonArray.size()); + } + } + } catch (NumberFormatException e) { + LOG.warning("Failed to parse pack version component as a number. Value: " + element, e); + } + + return UNSPECIFIED; + } + + public boolean isUnspecified() { + return getMin().isUnspecified() || getMax().isUnspecified() || getMin().compareTo(getMax()) > 0; + } + + public PackVersion getMin() { + return new PackVersion(min, 0); + } + + public PackVersion getMax() { + return new PackVersion(max, 0); + } + + } + public record PackVersion(int majorVersion, int minorVersion) implements Comparable { - public static final PackVersion UNSPECIFIED = new PackVersion(0, 0); + public static final PackVersion UNSPECIFIED = new PackVersion(-1, -1); @Override - public String toString() { + public @NotNull String toString() { + if (isUnspecified()) return "UNSPECIFIED"; return minorVersion != 0 ? majorVersion + "." + minorVersion : String.valueOf(majorVersion); } @@ -81,7 +114,7 @@ public int compareTo(PackVersion other) { } public boolean isUnspecified() { - return this.equals(UNSPECIFIED); + return this.majorVersion < 0 || this.minorVersion < 0; } public static PackVersion fromJson(JsonElement element) throws JsonParseException { @@ -175,25 +208,13 @@ public PackInfo deserialize(JsonElement json, Type typeOfT, JsonDeserializationC } else { packFormat = 0; } + SupportedFormats supportedFormats = SupportedFormats.fromJson(packInfo.get("supported_formats")); PackVersion minVersion = PackVersion.fromJson(packInfo.get("min_format")); PackVersion maxVersion = PackVersion.fromJson(packInfo.get("max_format")); List parts = parseDescription(packInfo.get("description")); - return new PackInfo(packFormat, minVersion, maxVersion, new LocalModFile.Description(parts)); + return new PackInfo(packFormat, supportedFormats, minVersion, maxVersion, new LocalModFile.Description(parts)); } } - public static LocalModFile fromFile(ModManager modManager, Path modFile, ZipFileTree tree) throws IOException, JsonParseException { - ZipArchiveEntry mcmod = tree.getEntry("pack.mcmeta"); - if (mcmod == null) - throw new IOException("File " + modFile + " is not a resource pack."); - PackMcMeta metadata = JsonUtils.fromNonNullJsonFully(tree.getInputStream(mcmod), PackMcMeta.class); - return new LocalModFile( - modManager, - modManager.getLocalMod(FileUtils.getNameWithoutExtension(modFile), ModLoaderType.PACK), - modFile, - FileUtils.getNameWithoutExtension(modFile), - metadata.pack.description, - "", "", "", "", ""); - } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackExportTask.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackExportTask.java index d2b2eddae5..0f7834b4b6 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackExportTask.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthModpackExportTask.java @@ -61,14 +61,14 @@ private ModrinthManifest.File tryGetRemoteFile(Path file, String relativePath) t Optional curseForgeVersion = Optional.empty(); try { - modrinthVersion = ModrinthRemoteModRepository.MODS.getRemoteVersionByLocalFile(localModFile, file); + modrinthVersion = ModrinthRemoteModRepository.MODS.getRemoteVersionByLocalFile(file); } catch (IOException e) { LOG.warning("Failed to get remote file from Modrinth for: " + file, e); } if (!info.isSkipCurseForgeRemoteFiles() && CurseForgeRemoteModRepository.isAvailable()) { try { - curseForgeVersion = CurseForgeRemoteModRepository.MODS.getRemoteVersionByLocalFile(localModFile, file); + curseForgeVersion = CurseForgeRemoteModRepository.MODS.getRemoteVersionByLocalFile(file); } catch (IOException e) { LOG.warning("Failed to get remote file from CurseForge for: " + file, e); } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java index 68fe206df4..66992e965d 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/mod/modrinth/ModrinthRemoteModRepository.java @@ -20,15 +20,10 @@ import com.google.gson.annotations.SerializedName; import com.google.gson.reflect.TypeToken; import org.jackhuang.hmcl.download.DownloadProvider; -import org.jackhuang.hmcl.mod.LocalModFile; import org.jackhuang.hmcl.mod.ModLoaderType; import org.jackhuang.hmcl.mod.RemoteMod; import org.jackhuang.hmcl.mod.RemoteModRepository; -import org.jackhuang.hmcl.util.DigestUtils; -import org.jackhuang.hmcl.util.Immutable; -import org.jackhuang.hmcl.util.Lang; -import org.jackhuang.hmcl.util.Pair; -import org.jackhuang.hmcl.util.StringUtils; +import org.jackhuang.hmcl.util.*; import org.jackhuang.hmcl.util.gson.JsonUtils; import org.jackhuang.hmcl.util.io.HttpRequest; import org.jackhuang.hmcl.util.io.NetworkUtils; @@ -39,13 +34,7 @@ import java.nio.file.NoSuchFileException; import java.nio.file.Path; import java.time.Instant; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Set; +import java.util.*; import java.util.concurrent.Semaphore; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -66,13 +55,21 @@ public final class ModrinthRemoteModRepository implements RemoteModRepository { private final String projectType; + private final RemoteModRepository.Type type; + private ModrinthRemoteModRepository(String projectType) { this.projectType = projectType; + this.type = switch (projectType) { + case "modpack" -> Type.MODPACK; + case "resourcepack" -> Type.RESOURCE_PACK; + case "shader" -> Type.SHADER_PACK; + default -> Type.MOD; + }; } @Override public Type getType() { - return Type.MOD; + return this.type; } private static String convertSortType(SortType sortType) { @@ -120,7 +117,7 @@ public SearchResult search(DownloadProvider downloadProvider, String gameVersion } @Override - public Optional getRemoteVersionByLocalFile(LocalModFile localModFile, Path file) throws IOException { + public Optional getRemoteVersionByLocalFile(Path file) throws IOException { String sha1 = DigestUtils.digestToString("SHA-1", file); SEMAPHORE.acquireUninterruptibly(); @@ -341,6 +338,12 @@ public Stream loadVersions(RemoteModRepository modRepository) } public RemoteMod toMod() { + RemoteModRepository.Type type = switch (projectType) { + case "modpack" -> RemoteModRepository.Type.MODPACK; + case "resourcepack" -> RemoteModRepository.Type.RESOURCE_PACK; + case "shader" -> RemoteModRepository.Type.SHADER_PACK; + default -> RemoteModRepository.Type.MOD; + }; return new RemoteMod( slug, "", @@ -349,7 +352,8 @@ public RemoteMod toMod() { categories, String.format("https://modrinth.com/%s/%s", projectType, id), iconUrl, - this + this, + type ); } } @@ -728,6 +732,12 @@ public Stream loadVersions(RemoteModRepository modRepository) } public RemoteMod toMod() { + RemoteModRepository.Type type = switch (projectType) { + case "modpack" -> RemoteModRepository.Type.MODPACK; + case "resourcepack" -> RemoteModRepository.Type.RESOURCE_PACK; + case "shader" -> RemoteModRepository.Type.SHADER_PACK; + default -> RemoteModRepository.Type.MOD; + }; return new RemoteMod( slug, author, @@ -736,7 +746,8 @@ public RemoteMod toMod() { displayCategories, String.format("https://modrinth.com/%s/%s", projectType, projectId), iconUrl, - this + this, + type ); } } diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFile.java deleted file mode 100644 index 6bfea93923..0000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFile.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.jackhuang.hmcl.resourcepack; - -import org.jackhuang.hmcl.mod.LocalModFile; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.Locale; - -public interface ResourcepackFile { - @Nullable - LocalModFile.Description getDescription(); - - String getName(); - - Path getPath(); - - byte @Nullable [] getIcon(); - - static ResourcepackFile parse(Path path) throws IOException { - String fileName = path.getFileName().toString(); - if (Files.isRegularFile(path) && fileName.toLowerCase(Locale.ROOT).endsWith(".zip")) { - return new ResourcepackZipFile(path); - } else if (Files.isDirectory(path) && Files.exists(path.resolve("pack.mcmeta"))) { - return new ResourcepackFolder(path); - } - return null; - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFolder.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFolder.java deleted file mode 100644 index 14b5d3c637..0000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackFolder.java +++ /dev/null @@ -1,60 +0,0 @@ -package org.jackhuang.hmcl.resourcepack; - -import org.jackhuang.hmcl.mod.LocalModFile; -import org.jackhuang.hmcl.mod.modinfo.PackMcMeta; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -public final class ResourcepackFolder implements ResourcepackFile { - private final Path path; - private final LocalModFile.Description description; - private final byte @Nullable [] icon; - - public ResourcepackFolder(Path path) { - this.path = path; - - LocalModFile.Description description = null; - try { - description = JsonUtils.fromJsonFile(path.resolve("pack.mcmeta"), PackMcMeta.class).pack().description(); - } catch (Exception e) { - LOG.warning("Failed to parse resourcepack meta", e); - } - - byte[] icon; - try { - icon = Files.readAllBytes(path.resolve("pack.png")); - } catch (IOException e) { - icon = null; - LOG.warning("Failed to read resourcepack icon", e); - } - this.icon = icon; - - this.description = description; - } - - @Override - public String getName() { - return path.getFileName().toString(); - } - - @Override - public Path getPath() { - return path; - } - - @Override - public LocalModFile.Description getDescription() { - return description; - } - - @Override - public byte @Nullable [] getIcon() { - return icon; - } -} diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackZipFile.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackZipFile.java deleted file mode 100644 index 8f35f933bc..0000000000 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/resourcepack/ResourcepackZipFile.java +++ /dev/null @@ -1,72 +0,0 @@ -package org.jackhuang.hmcl.resourcepack; - -import org.jackhuang.hmcl.mod.LocalModFile; -import org.jackhuang.hmcl.mod.modinfo.PackMcMeta; -import org.jackhuang.hmcl.util.gson.JsonUtils; -import org.jackhuang.hmcl.util.io.CompressingUtils; -import org.jackhuang.hmcl.util.io.FileUtils; -import org.jackhuang.hmcl.util.tree.ZipFileTree; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Path; - -import static org.jackhuang.hmcl.util.logging.Logger.LOG; - -public final class ResourcepackZipFile implements ResourcepackFile { - private final Path path; - private final byte @Nullable [] icon; - private final String name; - private final LocalModFile.Description description; - - public ResourcepackZipFile(Path path) throws IOException { - this.path = path; - LocalModFile.Description description = null; - - byte[] icon = null; - - try (var zipFileTree = new ZipFileTree(CompressingUtils.openZipFile(path))) { - try { - description = JsonUtils.fromNonNullJson(zipFileTree.readTextEntry("/pack.mcmeta"), PackMcMeta.class).pack().description(); - } catch (Exception e) { - LOG.warning("Failed to parse resourcepack meta", e); - } - - var iconEntry = zipFileTree.getEntry("/pack.png"); - if (iconEntry != null) { - try (InputStream is = zipFileTree.getInputStream(iconEntry)) { - icon = is.readAllBytes(); - } catch (Exception e) { - LOG.warning("Failed to load resourcepack icon", e); - } - } - } - - this.icon = icon; - this.description = description; - - name = FileUtils.getNameWithoutExtension(path); - } - - @Override - public String getName() { - return name; - } - - @Override - public Path getPath() { - return path; - } - - @Override - public LocalModFile.Description getDescription() { - return description; - } - - @Override - public byte @Nullable [] getIcon() { - return icon; - } -} - diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java index 26456b8f75..547f34b3a2 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/StringUtils.java @@ -17,6 +17,9 @@ */ package org.jackhuang.hmcl.util; +import org.jackhuang.hmcl.util.gson.JsonUtils; +import org.jetbrains.annotations.Contract; + import java.io.PrintWriter; import java.io.StringWriter; import java.util.*; @@ -529,6 +532,28 @@ public static boolean isAlphabeticOrNumber(String str) { return true; } + /// Turns `List.of("a", "b", "c")` into `["a", "b", "c"]` + @Contract(pure = true) + public static String serializeStringList(List list) { + if (list == null) return "[]"; + try { + return JsonUtils.UGLY_GSON.toJson(list.stream().filter(Objects::nonNull).toList(), JsonUtils.listTypeOf(String.class).getType()); + } catch (Exception e) { + return "[]"; + } + } + + /// Turns `["a", "b", "c"]` into `List.of("a", "b", "c")` + @Contract(pure = true) + public static List deserializeStringList(String list) { + if (list == null || list.isBlank()) return List.of(); + try { + return JsonUtils.fromNonNullJson(list, JsonUtils.listTypeOf(String.class)); + } catch (Exception e) { + return List.of(); + } + } + public static class LevCalculator { private int[][] lev; diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java index 97290f6280..0ff7a8b734 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/io/FileUtils.java @@ -99,7 +99,11 @@ public static String getNameWithoutExtension(String fileName) { } public static String getNameWithoutExtension(Path file) { - return StringUtils.substringBeforeLast(getName(file), '.'); + String name = getName(file); + if (Files.isDirectory(file)) { + return name; + } + return StringUtils.substringBeforeLast(name, '.'); } public static String getExtension(String fileName) { diff --git a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/VersionRange.java b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/VersionRange.java index c0d6cd1912..17845fecb1 100644 --- a/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/VersionRange.java +++ b/HMCLCore/src/main/java/org/jackhuang/hmcl/util/versioning/VersionRange.java @@ -33,6 +33,11 @@ public static > VersionRange atMost(T maximum) { return new VersionRange<>(null, maximum); } + public static > VersionRange is(T version) { + assert version != null; + return new VersionRange<>(version, version); + } + private final T minimum; private final T maximum; diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/mod/ResourcePackManagerTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/mod/ResourcePackManagerTest.java new file mode 100644 index 0000000000..c4815e457e --- /dev/null +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/mod/ResourcePackManagerTest.java @@ -0,0 +1,27 @@ +package org.jackhuang.hmcl.mod; + +import org.jackhuang.hmcl.util.versioning.GameVersionNumber; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class ResourcePackManagerTest { + + @Test + void testIsMcVersionSupported() { + assertTrue(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("26.1-snapshot-1"))); + assertTrue(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("25w14craftmine"))); + assertTrue(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("1.21"))); + assertTrue(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("1.16.5"))); + assertTrue(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("1.13-pre3"))); + assertTrue(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("17w48a"))); + assertTrue(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("13w24a"))); + assertTrue(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("1.6.1"))); + + assertFalse(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("13w23a"))); + assertFalse(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("1.6"))); + assertFalse(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("13w23a"))); + assertFalse(ResourcePackManager.isMcVersionSupported(GameVersionNumber.asGameVersion("b1.1-1"))); + } +} diff --git a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/StringUtilsTest.java b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/StringUtilsTest.java index 46017d1f84..1ef52560e7 100644 --- a/HMCLCore/src/test/java/org/jackhuang/hmcl/util/StringUtilsTest.java +++ b/HMCLCore/src/test/java/org/jackhuang/hmcl/util/StringUtilsTest.java @@ -19,6 +19,9 @@ import org.junit.jupiter.api.Test; +import java.util.Arrays; +import java.util.List; + import static org.junit.jupiter.api.Assertions.assertEquals; /** @@ -47,4 +50,29 @@ public void testNormalizeWhitespaces() { assertEquals("a b c", StringUtils.normalizeWhitespaces(" a \t b c ")); assertEquals("a b c", StringUtils.normalizeWhitespaces(" a \t b c ")); } + + @Test + public void testSerializeStringList() { + assertEquals("[]", StringUtils.serializeStringList(null)); + assertEquals("[]", StringUtils.serializeStringList(List.of())); + assertEquals("[]", StringUtils.serializeStringList(Arrays.asList((String) null))); + assertEquals("[\"hello\"]", StringUtils.serializeStringList(List.of("hello"))); + assertEquals("[\"hello\"]", StringUtils.serializeStringList(Arrays.asList("hello", null))); + assertEquals("[\"he\\\"llo\"]", StringUtils.serializeStringList(Arrays.asList("he\"llo", null))); + assertEquals("[\"hello\",\"world\"]", StringUtils.serializeStringList(List.of("hello", "world"))); + } + + @Test + public void testDeserializeStringList() { + assertEquals(List.of(), StringUtils.deserializeStringList(null)); + assertEquals(List.of(), StringUtils.deserializeStringList("[]")); + assertEquals(List.of(), StringUtils.deserializeStringList("[ ]")); + assertEquals(List.of(), StringUtils.deserializeStringList("[\"]")); + assertEquals(List.of(), StringUtils.deserializeStringList("[\"he\"llo\"]")); + assertEquals(Arrays.asList((String) null), StringUtils.deserializeStringList("[null]")); + assertEquals(List.of("hello"), StringUtils.deserializeStringList("[\"hello\"]")); + assertEquals(List.of("he\"llo"), StringUtils.deserializeStringList("[\"he\\\"llo\"]")); + assertEquals(List.of("hello", "world"), StringUtils.deserializeStringList("[\"hello\",\"world\"]")); + assertEquals(List.of("hello", "world"), StringUtils.deserializeStringList("[\"hello\",\n\"world\"]")); + } }