1
0
mirror of https://github.com/SKCraft/Launcher.git synced 2024-11-30 13:13:58 +01:00

Flesh out curse mod manager

This commit is contained in:
Henry Le Grys 2021-03-29 01:20:32 +01:00
parent 60adb4fde5
commit 66b7b44ac8
12 changed files with 482 additions and 24 deletions

View File

@ -1,5 +1,49 @@
package com.skcraft.plugin.curse;
public class CurseApi {
import com.fasterxml.jackson.core.type.TypeReference;
import com.skcraft.launcher.util.HttpRequest;
import com.skcraft.plugin.curse.model.CurseProject;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.List;
import static com.skcraft.launcher.util.HttpRequest.url;
public class CurseApi {
private static final String CURSE_ADDON_SEARCH = "https://addons-ecs.forgesvc.net/api/v2/addon/search?";
private static final String MINECRAFT_GAME_ID = "432";
private static final String CURSE_MOD_SECTION_ID = "6";
public static List<CurseProject> searchForProjects(String query, String gameVersion) throws IOException, InterruptedException {
HttpRequest.Form form = HttpRequest.Form.form();
form.add("gameID", MINECRAFT_GAME_ID);
form.add("sectionId", CURSE_MOD_SECTION_ID); // Filter to mods only
form.add("gameVersion", gameVersion);
form.add("searchFilter", query);
try {
URI uri = new URI(CURSE_ADDON_SEARCH + form.toString());
return HttpRequest.get(uri.toURL())
.execute()
.expectResponseCode(200)
.returnContent()
.asJson(new TypeReference<List<CurseProject>>() {});
} catch (URISyntaxException | MalformedURLException e) {
// Shhhh.
throw new RuntimeException(e);
}
}
public static CurseProject getById(String projectId) throws IOException, InterruptedException {
String addonUrl = String.format("https://addons-ecs.forgesvc.net/api/v2/addon/%s", projectId);
return HttpRequest.get(url(addonUrl))
.execute()
.expectResponseCode(200)
.returnContent()
.asJson(CurseProject.class);
}
}

View File

@ -2,9 +2,13 @@ package com.skcraft.plugin.curse;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.skcraft.launcher.model.modpack.Feature;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class CurseMod {
private String projectId;

View File

@ -1,54 +1,275 @@
package com.skcraft.plugin.curse.creator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.ListeningExecutorService;
import com.skcraft.concurrency.DefaultProgress;
import com.skcraft.launcher.creator.model.creator.Pack;
import com.skcraft.launcher.creator.plugin.MenuContext;
import com.skcraft.launcher.creator.plugin.PluginMenu;
import com.skcraft.launcher.dialog.ProgressDialog;
import com.skcraft.launcher.swing.EmptyIcon;
import com.skcraft.launcher.swing.LinedBoxPanel;
import com.skcraft.plugin.curse.model.CurseProject;
import com.skcraft.launcher.swing.SwingHelper;
import com.skcraft.launcher.util.SwingExecutor;
import com.skcraft.plugin.curse.CurseApi;
import com.skcraft.plugin.curse.model.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.java.Log;
import javax.imageio.ImageIO;
import javax.swing.*;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.image.BufferedImage;
import java.io.File;
import java.util.HashMap;
import java.util.List;
import java.util.concurrent.ExecutionException;
import java.util.function.Consumer;
import static com.skcraft.launcher.util.HttpRequest.url;
@Log
public class CurseModsDialog extends JDialog {
private final LinedBoxPanel panel = new LinedBoxPanel(false).fullyPadded();
private final JList<CurseProject> searchResults = new JList<>();
private final JList<CurseProject> selected = new JList<>();
private final JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, searchResults, selected);
private final JButton addMod = new JButton("Add Mod >>");
private final JButton removeMod = new JButton("Remove Mod");
private final CurseSearchResults searchResults = new CurseSearchResults();
private final LoadedModList modList = new LoadedModList();
private final JPanel panel = new JPanel(new BorderLayout(0, 5));
private final JTextField searchBox = new JTextField();
private final JButton searchButton = new JButton("Search");
private final JList<CurseProject> searchPane = new JList<>(searchResults);
private final JList<LoadedMod> selectedPane = new JList<>(modList);
private final JSplitPane splitPane = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, new JScrollPane(searchPane), new JScrollPane(selectedPane));
private final JButton addMod = new JButton("Add Mods >>");
private final JButton removeMod = new JButton("Remove Mods");
private final JButton done = new JButton("Done");
private final CurseProjectListRenderer projectRenderer = new CurseProjectListRenderer();
private final ListeningExecutorService executor;
private final ObjectMapper mapper;
private final Pack pack;
private final File curseModsDir;
public CurseModsDialog(Window owner, Pack pack) {
public CurseModsDialog(Window owner, ListeningExecutorService executor, ObjectMapper mapper, Pack pack, List<LoadedMod> currentMods) {
super(owner);
this.executor = executor;
this.mapper = mapper;
this.pack = pack;
this.curseModsDir = new File(pack.getDirectory(), "cursemods");
modList.addAll(currentMods);
initComponents();
setMinimumSize(new Dimension(500, 250));
setMinimumSize(new Dimension(500, 450));
pack();
setLocationRelativeTo(owner);
}
private void initComponents() {
splitPane.setDividerLocation(0.5D);
searchPane.setCellRenderer(projectRenderer);
searchPane.setLayoutOrientation(JList.VERTICAL);
searchPane.setVisibleRowCount(0);
searchPane.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
selectedPane.setCellRenderer(projectRenderer);
selectedPane.setLayoutOrientation(JList.VERTICAL);
selectedPane.setVisibleRowCount(0);
selectedPane.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
splitPane.setDividerLocation(250);
splitPane.setDividerSize(10);
LinedBoxPanel searchBarPanel = new LinedBoxPanel(true);
searchBarPanel.addElement(searchBox);
searchBarPanel.addElement(searchButton);
searchBarPanel.setAlignmentX(CENTER_ALIGNMENT);
LinedBoxPanel buttonsPanel = new LinedBoxPanel(true);
buttonsPanel.addElement(addMod);
buttonsPanel.addGlue();
buttonsPanel.addElement(done);
buttonsPanel.addGlue();
buttonsPanel.addElement(removeMod);
buttonsPanel.setAlignmentX(CENTER_ALIGNMENT);
panel.addElement(splitPane);
panel.addElement(buttonsPanel);
panel.add(searchBarPanel, BorderLayout.NORTH);
panel.add(Box.createVerticalStrut(5));
panel.add(splitPane, BorderLayout.CENTER);
panel.add(buttonsPanel, BorderLayout.SOUTH);
add(panel);
addMod.addActionListener(e -> {
searchBox.addActionListener(e -> search());
searchButton.addActionListener(e -> search());
addMod.addActionListener(e -> {
for (CurseProject project : searchPane.getSelectedValuesList()) {
GameVersionFile forVersion = project.findFileForVersion(pack.getCachedConfig().getGameVersion());
if (forVersion == null) {
SwingHelper.showErrorDialog(
getOwner(),
String.format("Mod %s isn't available for this version.", project.getName()),
"Mod Unavailable"
);
return;
}
LoadedMod loadedMod = project.toLoadedMod(forVersion);
modList.add(loadedMod);
executor.submit(() -> {
File target = loadedMod.getDiskLocation(curseModsDir);
log.info(String.format("Saving mod %s", target.getName()));
mapper.writeValue(target, loadedMod.getMod());
return null;
});
}
});
removeMod.addActionListener(e -> {
for (LoadedMod mod : selectedPane.getSelectedValuesList()) {
modList.remove(mod);
File target = mod.getDiskLocation(curseModsDir);
log.info(String.format("Removing mod %s", target.getName()));
if (!target.delete()) {
SwingHelper.showErrorDialog(getOwner(), String.format("Failed to delete %s", target), "I/O error");
}
}
});
done.addActionListener(e -> dispose());
}
@Override
public void dispose() {
// Let's make sure the image cache is emptied
projectRenderer.clearCache();
super.dispose();
}
private void search() {
String query = searchBox.getText();
ListenableFuture<List<CurseProject>> future = executor.submit(() ->
CurseApi.searchForProjects(query, pack.getCachedConfig().getGameVersion()));
Futures.addCallback(future, new FutureCallback<List<CurseProject>>() {
@Override
public void onSuccess(List<CurseProject> result) {
projectRenderer.clearCacheIfBig();
searchResults.updateResults(result);
}
@Override
public void onFailure(Throwable t) {
SwingHelper.showErrorDialog(getOwner(), t.getMessage(), "Search failure", t);
}
}, SwingExecutor.INSTANCE);
}
@RequiredArgsConstructor
private static class ImageWorker extends SwingWorker<BufferedImage, BufferedImage> {
private final String imgUrl;
private final Consumer<BufferedImage> consumer;
@Override
protected BufferedImage doInBackground() throws Exception {
return ImageIO.read(url(imgUrl));
}
@Override
protected void done() {
try {
consumer.accept(this.get());
} catch (InterruptedException | ExecutionException e) {
SwingHelper.showErrorDialog(null, e.getMessage(), "Worker error", e);
}
}
public static void downloadImage(String imageUrl, Consumer<BufferedImage> consumer) {
ImageWorker worker = new ImageWorker(imageUrl, consumer);
worker.execute();
}
}
private static class CurseProjectListRenderer extends JLabel implements ListCellRenderer<ProjectHolder> {
private HashMap<String, BufferedImage> cache;
public CurseProjectListRenderer() {
this.cache = new HashMap<>();
}
private BufferedImage getCachedIcon(String imgUrl, Runnable cb) {
if (!cache.containsKey(imgUrl)) {
ImageWorker.downloadImage(imgUrl, img -> {
cache.put(imgUrl, img);
cb.run();
});
return null;
}
return cache.get(imgUrl);
}
public void clearCache() {
this.cache.clear();
}
public void clearCacheIfBig() {
if (this.cache.size() > 200) {
this.cache.clear();
}
}
private static Dimension getScaledDimensions(int imageWidth, int imageHeight) {
double widthRatio = 32.0D / imageWidth;
double heightRatio = 32.0D / imageHeight;
double ratio = Math.min(widthRatio, heightRatio);
int newWidth = (int) Math.floor(imageWidth * ratio);
int newHeight = (int) Math.floor(imageHeight * ratio);
return new Dimension(newWidth, newHeight);
}
@Override
public Component getListCellRendererComponent(JList<? extends ProjectHolder> list, ProjectHolder value, int index, boolean isSelected, boolean cellHasFocus) {
CurseProject project = value.getProject();
setText(project.getName());
setToolTipText(project.getSummary());
BufferedImage ref = getCachedIcon(project.getDefaultIcon().getThumbnailUrl(), list::repaint);
if (ref != null) {
Dimension scaled = getScaledDimensions(ref.getWidth(), ref.getHeight());
setIcon(new ImageIcon(ref.getScaledInstance(scaled.width, scaled.height, Image.SCALE_SMOOTH)));
} else {
setIcon(new EmptyIcon(32 ,32));
}
if (isSelected) {
setOpaque(true);
setBackground(list.getSelectionBackground());
setForeground(list.getSelectionForeground());
} else {
setOpaque(false);
setForeground(list.getForeground());
}
return this;
}
}
public static class Menu implements PluginMenu {
private final ObjectMapper mapper = new ObjectMapper();
@Override
public String getTitle() {
return "Add Mods";
@ -60,9 +281,23 @@ public class CurseModsDialog extends JDialog {
}
@Override
public void onOpen(Window owner, ActionEvent e, Pack pack) {
CurseModsDialog dialog = new CurseModsDialog(owner, pack);
dialog.setVisible(true);
public void onOpen(MenuContext ctx, ActionEvent e, Pack pack) {
PackModScanner scanner = new PackModScanner(mapper);
ListenableFuture<?> future = ctx.getExecutor().submit(() -> {
scanner.walk(new File(pack.getDirectory(), "cursemods"));
return null;
});
ProgressDialog.showProgress(ctx.getOwner(), future,
new DefaultProgress(-1, "Loading mods..."),"Curse", "Contacting Curse API");
SwingHelper.addErrorDialogCallback(ctx.getOwner(), future);
future.addListener(() -> {
CurseModsDialog dialog = new CurseModsDialog(ctx.getOwner(), ctx.getExecutor(), mapper, pack, scanner.getResult());
dialog.setTitle(getTitle());
dialog.setVisible(true);
}, SwingExecutor.INSTANCE);
}
}
}

View File

@ -0,0 +1,32 @@
package com.skcraft.plugin.curse.creator;
import com.beust.jcommander.internal.Lists;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.skcraft.launcher.builder.DirectoryWalker;
import com.skcraft.plugin.curse.CurseApi;
import com.skcraft.plugin.curse.CurseMod;
import com.skcraft.plugin.curse.model.CurseProject;
import com.skcraft.plugin.curse.model.LoadedMod;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import lombok.SneakyThrows;
import java.io.File;
import java.io.IOException;
import java.util.List;
@RequiredArgsConstructor
public class PackModScanner extends DirectoryWalker {
private final ObjectMapper mapper;
@Getter private final List<LoadedMod> result = Lists.newArrayList();
@Override
@SneakyThrows
protected void onFile(File file, String relPath) throws IOException {
CurseMod mod = mapper.readValue(file, CurseMod.class);
CurseProject project = CurseApi.getById(mod.getProjectId());
result.add(new LoadedMod(mod, project));
}
}

View File

@ -1,19 +1,27 @@
package com.skcraft.plugin.curse.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.skcraft.plugin.curse.CurseMod;
import lombok.Data;
import java.util.List;
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public class CurseProject {
public class CurseProject implements ProjectHolder {
private String id;
private String name;
private String websiteUrl;
private String summary;
private String slug;
private List<GameVersionFile> gameVersionLatestFiles;
private List<Attachment> attachments;
@Override
public CurseProject getProject() {
return this;
}
public GameVersionFile findFileForVersion(String version) {
for (GameVersionFile file : gameVersionLatestFiles) {
@ -24,4 +32,34 @@ public class CurseProject {
return null;
}
public LoadedMod toLoadedMod(GameVersionFile versionFile) {
CurseMod mod = new CurseMod(id, versionFile.getProjectFileId(), null);
return new LoadedMod(mod, this);
}
public Attachment getDefaultIcon() {
for (Attachment attachment : attachments) {
if (attachment.isDefault()) {
return attachment;
}
}
if (!attachments.isEmpty()) {
return attachments.get(0);
}
return null;
}
@Data
@JsonIgnoreProperties(ignoreUnknown = true)
public static class Attachment {
private String title;
private String thumbnailUrl;
private String url;
@JsonProperty("isDefault")
private boolean isDefault;
}
}

View File

@ -0,0 +1,27 @@
package com.skcraft.plugin.curse.model;
import com.beust.jcommander.internal.Lists;
import javax.swing.*;
import java.util.List;
public class CurseSearchResults extends AbstractListModel<CurseProject> {
private List<CurseProject> results = Lists.newArrayList();
@Override
public int getSize() {
return results.size();
}
@Override
public CurseProject getElementAt(int index) {
return results.get(index);
}
public void updateResults(List<CurseProject> newResults) {
int before = results.size();
results = newResults;
this.fireContentsChanged(this, 0, before - 1);
}
}

View File

@ -0,0 +1,19 @@
package com.skcraft.plugin.curse.model;
import com.skcraft.plugin.curse.CurseMod;
import lombok.Data;
import java.io.File;
/**
* Represents mod metadata cached in-memory for the purpose of rendering the "Add Mods" screen.
*/
@Data
public class LoadedMod implements ProjectHolder {
private final CurseMod mod;
private final CurseProject project;
public File getDiskLocation(File curseModsDir) {
return new File(curseModsDir, String.format("%s.json", project.getSlug()));
}
}

View File

@ -0,0 +1,43 @@
package com.skcraft.plugin.curse.model;
import com.google.common.collect.Lists;
import javax.swing.*;
import java.util.Collection;
import java.util.List;
public class LoadedModList extends AbstractListModel<LoadedMod> {
private List<LoadedMod> mods = Lists.newArrayList();
@Override
public int getSize() {
return mods.size();
}
@Override
public LoadedMod getElementAt(int index) {
return mods.get(index);
}
public void addAll(Collection<? extends LoadedMod> other) {
int start = mods.size();
mods.addAll(other);
fireIntervalAdded(this, start, start + other.size());
}
public void add(LoadedMod mod) {
mods.add(mod);
int idx = mods.size() - 1;
fireIntervalAdded(this, idx, idx);
}
public void remove(LoadedMod mod) {
int index = mods.indexOf(mod);
if (index > -1) {
mods.remove(index);
fireIntervalRemoved(this, index, index);
}
}
}

View File

@ -0,0 +1,5 @@
package com.skcraft.plugin.curse.model;
public interface ProjectHolder {
CurseProject getProject();
}

View File

@ -33,6 +33,7 @@ import com.skcraft.launcher.creator.model.creator.*;
import com.skcraft.launcher.creator.model.swing.PackTableModel;
import com.skcraft.launcher.creator.plugin.CreatorPluginWrapper;
import com.skcraft.launcher.creator.plugin.CreatorToolsPlugin;
import com.skcraft.launcher.creator.plugin.MenuContext;
import com.skcraft.launcher.creator.plugin.PluginMenu;
import com.skcraft.launcher.creator.server.TestServer;
import com.skcraft.launcher.creator.server.TestServerBuilder;
@ -335,7 +336,7 @@ public class PackManagerController {
return;
}
menu.onOpen(frame, e, pack.orNull());
menu.onOpen(new MenuContext(frame, creator.getExecutor()), e, pack.orNull());
});
submenu.add(item);

View File

@ -0,0 +1,12 @@
package com.skcraft.launcher.creator.plugin;
import com.google.common.util.concurrent.ListeningExecutorService;
import lombok.Data;
import java.awt.*;
@Data
public class MenuContext {
private final Window owner;
private final ListeningExecutorService executor;
}

View File

@ -2,14 +2,13 @@ package com.skcraft.launcher.creator.plugin;
import com.skcraft.launcher.creator.model.creator.Pack;
import java.awt.*;
import java.awt.event.ActionEvent;
public interface PluginMenu {
String getTitle();
/**
* Return true if {@link PluginMenu#onOpen(Window, ActionEvent, Pack)} should only be called
* Return true if {@link PluginMenu#onOpen(MenuContext, ActionEvent, Pack)} should only be called
* if the user has selected a pack.
* @return True to require a pack, false if you don't need one.
*/
@ -17,10 +16,9 @@ public interface PluginMenu {
/**
* Called when the menu item was clicked.
*
* @param owner Window reference for parenting dialogs.
* @param ctx Context object with various useful objects.
* @param e Action event that triggered this call.
* @param pack Pack to operate on; guaranteed to be non-null if {@link PluginMenu#requiresPack()} returns true.
*/
void onOpen(Window owner, ActionEvent e, Pack pack);
void onOpen(MenuContext ctx, ActionEvent e, Pack pack);
}