diff --git a/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java b/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java
index 34d6ee3de..40598ac1e 100644
--- a/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java
+++ b/recaf-ui/src/main/java/software/coley/recaf/services/window/WindowManager.java
@@ -43,6 +43,7 @@ public class WindowManager implements Service {
public static final String WIN_CONFIG = "config";
public static final String WIN_INFO = "system-information";
public static final String WIN_SCRIPTS = "script-manager";
+ public static final String WIN_PLUGINS = "plugin-manager";
public static final String WIN_MAP_PROGRESS = "mapping-progress";
public static final String WIN_QUICK_NAV = "quick-nav";
// Manager instance data
@@ -230,6 +231,14 @@ public Stage getScriptManagerWindow() {
return Objects.requireNonNull(getWindow(WIN_SCRIPTS));
}
+ /**
+ * @return Window for the plugin manager display.
+ */
+ @Nonnull
+ public Stage getPluginManagerWindow() {
+ return Objects.requireNonNull(getWindow(WIN_PLUGINS));
+ }
+
/**
* @return Window for the current mapping preview display.
*/
diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/MainMenu.java b/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/MainMenu.java
index 6741afeed..a8aeca9cc 100644
--- a/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/MainMenu.java
+++ b/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/MainMenu.java
@@ -18,6 +18,7 @@ public class MainMenu extends MenuBar {
private final MappingMenu mappingMenu;
private final AnalysisMenu analysisMenu;
private final ScriptMenu scriptMenu;
+ private final PluginMenu pluginMenu;
private final HelpMenu helpMenu;
/**
@@ -36,6 +37,8 @@ public class MainMenu extends MenuBar {
* Analysis menu instance.
* @param scriptMenu
* Script menu instance.
+ * @param pluginMenu
+ * Plugin menu instance.
* @param helpMenu
* Help menu instance.
*/
@@ -45,6 +48,7 @@ public class MainMenu extends MenuBar {
@Nonnull MappingMenu mappingMenu,
@Nonnull AnalysisMenu analysisMenu,
@Nonnull ScriptMenu scriptMenu,
+ @Nonnull PluginMenu pluginMenu,
@Nonnull HelpMenu helpMenu) {
this.fileMenu = fileMenu;
this.configMenu = configMenu;
@@ -52,9 +56,10 @@ public class MainMenu extends MenuBar {
this.mappingMenu = mappingMenu;
this.analysisMenu = analysisMenu;
this.scriptMenu = scriptMenu;
+ this.pluginMenu = pluginMenu;
this.helpMenu = helpMenu;
- getMenus().addAll(fileMenu, configMenu, searchMenu, mappingMenu, analysisMenu, scriptMenu, helpMenu);
+ getMenus().addAll(fileMenu, configMenu, searchMenu, mappingMenu, analysisMenu, scriptMenu, pluginMenu, helpMenu);
setPadding(new Insets(0, 0, 2, 0));
}
@@ -94,6 +99,12 @@ public ScriptMenu getScriptMenu() {
return scriptMenu;
}
+ /** @return Plugin menu instance. */
+ @Nonnull
+ public PluginMenu getPluginMenu() {
+ return pluginMenu;
+ }
+
/** @return Help menu instance. */
@Nonnull
public HelpMenu getHelpMenu() {
diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/MainMenuProvider.java b/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/MainMenuProvider.java
index 70d751a70..5431670e2 100644
--- a/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/MainMenuProvider.java
+++ b/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/MainMenuProvider.java
@@ -21,8 +21,9 @@ public MainMenuProvider(@Nonnull FileMenu fileMenu,
@Nonnull MappingMenu mappingMenu,
@Nonnull AnalysisMenu analysisMenu,
@Nonnull ScriptMenu scriptMenu,
+ @Nonnull PluginMenu pluginMenu,
@Nonnull HelpMenu helpMenu) {
- this.mainMenu = new MainMenu(fileMenu, configMenu, searchMenu, mappingMenu, analysisMenu, scriptMenu, helpMenu);
+ this.mainMenu = new MainMenu(fileMenu, configMenu, searchMenu, mappingMenu, analysisMenu, scriptMenu, pluginMenu, helpMenu);
}
/**
diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/PluginMenu.java b/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/PluginMenu.java
new file mode 100644
index 000000000..9beb30317
--- /dev/null
+++ b/recaf-ui/src/main/java/software/coley/recaf/ui/menubar/PluginMenu.java
@@ -0,0 +1,75 @@
+package software.coley.recaf.ui.menubar;
+
+import jakarta.annotation.Nonnull;
+import jakarta.enterprise.context.Dependent;
+import jakarta.inject.Inject;
+import javafx.scene.control.Menu;
+import javafx.stage.Stage;
+import org.kordamp.ikonli.carbonicons.CarbonIcons;
+import org.slf4j.Logger;
+import software.coley.recaf.analytics.logging.Logging;
+import software.coley.recaf.services.window.WindowManager;
+import software.coley.recaf.ui.control.FontIconView;
+import software.coley.recaf.ui.pane.PluginManagerPane;
+import software.coley.recaf.util.DesktopUtil;
+
+import java.net.URI;
+
+import static software.coley.recaf.util.Lang.getBinding;
+import static software.coley.recaf.util.Menus.action;
+
+/**
+ * Plugin menu component for {@link MainMenu}.
+ *
+ * @author Canrad
+ * @see PluginManagerPane The manager display this menu links to.
+ */
+@Dependent
+public class PluginMenu extends Menu {
+ private static final Logger logger = Logging.get(PluginMenu.class);
+ private final WindowManager windowManager;
+
+ @Inject
+ public PluginMenu(@Nonnull WindowManager windowManager) {
+ this.windowManager = windowManager;
+
+ textProperty().bind(getBinding("menu.plugin"));
+ setGraphic(new FontIconView(CarbonIcons.PLUG));
+
+ // Browsing the plugin directory lives inside the manager panel, so the menu only opens the panel + doc links.
+ getItems().add(action("menu.plugin.manage", CarbonIcons.SETTINGS_ADJUST, this::openManager));
+ getItems().add(action("menu.plugin.devguide", CarbonIcons.NOTEBOOK_REFERENCE, this::openDevGuide));
+ getItems().add(action("menu.plugin.template", CarbonIcons.LOGO_GITHUB, this::openTemplate));
+ }
+
+ /**
+ * Display the plugin manager window.
+ */
+ private void openManager() {
+ Stage pluginWindow = windowManager.getPluginManagerWindow();
+ pluginWindow.show();
+ pluginWindow.requestFocus();
+ }
+
+ /**
+ * Opens the online plugin development guide.
+ */
+ private void openDevGuide() {
+ browseUrl(PluginManagerPane.URL_DEV_GUIDE);
+ }
+
+ /**
+ * Opens the template workspace for starting a new plugin project.
+ */
+ private void openTemplate() {
+ browseUrl(PluginManagerPane.URL_TEMPLATE_WORKSPACE);
+ }
+
+ private static void browseUrl(@Nonnull String uri) {
+ try {
+ DesktopUtil.showDocument(new URI(uri));
+ } catch (Exception ex) {
+ logger.error("Failed to open link: {}", uri, ex);
+ }
+ }
+}
diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/pane/PluginManagerPane.java b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/PluginManagerPane.java
new file mode 100644
index 000000000..cc9f2df23
--- /dev/null
+++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/PluginManagerPane.java
@@ -0,0 +1,543 @@
+package software.coley.recaf.ui.pane;
+
+import atlantafx.base.theme.Styles;
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import jakarta.enterprise.context.Dependent;
+import jakarta.inject.Inject;
+import javafx.beans.binding.StringBinding;
+import javafx.geometry.Insets;
+import javafx.geometry.Pos;
+import javafx.scene.control.Alert;
+import javafx.scene.control.ButtonType;
+import javafx.scene.control.CheckBox;
+import javafx.scene.control.Label;
+import javafx.scene.control.ScrollPane;
+import javafx.scene.layout.BorderPane;
+import javafx.scene.layout.HBox;
+import javafx.scene.layout.VBox;
+import javafx.stage.FileChooser;
+import org.kordamp.ikonli.carbonicons.CarbonIcons;
+import org.slf4j.Logger;
+import software.coley.recaf.analytics.logging.Logging;
+import software.coley.recaf.services.file.RecafDirectoriesConfig;
+import software.coley.recaf.services.plugin.PluginException;
+import software.coley.recaf.services.plugin.PluginInfo;
+import software.coley.recaf.services.plugin.PluginManager;
+import software.coley.recaf.services.plugin.PluginUnloader;
+import software.coley.recaf.services.plugin.PreparedPlugin;
+import software.coley.recaf.services.plugin.discovery.PathPluginDiscoverer;
+import software.coley.recaf.services.plugin.discovery.PluginDiscoverer;
+import software.coley.recaf.services.plugin.zip.ZipPluginLoader;
+import software.coley.recaf.ui.control.ActionButton;
+import software.coley.recaf.ui.control.FontIconView;
+import software.coley.recaf.util.DesktopUtil;
+import software.coley.recaf.util.FileChooserBuilder;
+import software.coley.recaf.util.ErrorDialogs;
+import software.coley.recaf.util.FxThreadUtil;
+import software.coley.recaf.util.Icons;
+import software.coley.recaf.util.Lang;
+import software.coley.recaf.util.io.ByteSources;
+import software.coley.recaf.util.threading.ThreadUtil;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.StandardCopyOption;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static software.coley.recaf.util.Lang.getBinding;
+
+/**
+ * Pane to display and manage installed plugins.
+ *
+ * Plugins are jar files residing in the {@link RecafDirectoriesConfig#getPluginDirectory() plugin directory}.
+ * Disabled plugins are moved into the {@link RecafDirectoriesConfig#getDisabledPluginDirectory() disabled sub-directory}
+ * which is not scanned at startup.
+ *
+ * @author Canrad
+ * @see PluginManager Source of loaded plugins.
+ */
+@Dependent
+public class PluginManagerPane extends BorderPane {
+ /** Link to the online plugin development guide. */
+ public static final String URL_DEV_GUIDE = "https://recaf.coley.software/dev/plugins-and-scripts/plugins.html";
+ /** Link to the template workspace for starting a new plugin project. */
+ public static final String URL_TEMPLATE_WORKSPACE = "https://github.com/Recaf-Plugins/Recaf-4x-plugin-workspace";
+ private static final Logger logger = Logging.get(PluginManagerPane.class);
+ private final VBox pluginList = new VBox();
+ private final PluginManager pluginManager;
+ private final RecafDirectoriesConfig directories;
+ private final ZipPluginLoader infoLoader = new ZipPluginLoader();
+
+ @Inject
+ public PluginManagerPane(@Nonnull PluginManager pluginManager,
+ @Nonnull RecafDirectoriesConfig directories) {
+ this(pluginManager, directories, false);
+ }
+
+ PluginManagerPane(@Nonnull PluginManager pluginManager,
+ @Nonnull RecafDirectoriesConfig directories,
+ boolean forTesting) {
+ this.pluginManager = pluginManager;
+ this.directories = directories;
+ // UI initialization is skipped when constructed for testing.
+ if (forTesting) return;
+
+ pluginList.setFillWidth(true);
+ pluginList.setSpacing(10);
+ pluginList.setPadding(new Insets(10));
+
+ ScrollPane scroll = new ScrollPane(pluginList);
+ scroll.getStyleClass().add("dark-scroll-pane");
+ scroll.setFitToWidth(true);
+ setCenter(scroll);
+
+ HBox controls = new HBox();
+ controls.setStyle("""
+ -fx-background-color: -color-bg-default;
+ -fx-border-color: -color-border-default;
+ -fx-border-width: 1 0 0 0;
+ """);
+ controls.setPadding(new Insets(10));
+ controls.setSpacing(10);
+ controls.setAlignment(Pos.CENTER_LEFT);
+ // The development guide & template workspace links live in the Plugins menu, so they aren't duplicated here.
+ controls.getChildren().addAll(
+ new ActionButton(CarbonIcons.DOCUMENT_ADD, getBinding("menu.plugin.install"), this::installPlugin),
+ new ActionButton(CarbonIcons.FOLDER, getBinding("menu.plugin.browse"), this::browse),
+ new ActionButton(CarbonIcons.RENEW, getBinding("menu.plugin.refresh"), this::refresh)
+ );
+ controls.getChildren().forEach(b -> b.getStyleClass().add("muted"));
+ setBottom(controls);
+
+ refresh();
+ }
+
+ /**
+ * Repopulate the plugin list from the contents of the plugin directories.
+ */
+ public void refresh() {
+ ThreadUtil.run(() -> {
+ List files = scanPluginFiles();
+ FxThreadUtil.run(() -> {
+ pluginList.getChildren().clear();
+ if (files.isEmpty()) {
+ Label noPlugins = new Label();
+ noPlugins.textProperty().bind(getBinding("menu.plugin.none-found"));
+ noPlugins.setGraphic(new FontIconView(CarbonIcons.SEARCH));
+ pluginList.getChildren().add(noPlugins);
+ } else {
+ for (LocalPluginFile file : files)
+ pluginList.getChildren().add(new PluginEntry(file));
+ }
+ });
+ });
+ }
+
+ /**
+ * @return Plugin files found in the enabled and disabled plugin directories.
+ */
+ @Nonnull
+ List scanPluginFiles() {
+ List files = new ArrayList<>();
+ collectPluginFiles(files, directories.getPluginDirectory(), true);
+ collectPluginFiles(files, directories.getDisabledPluginDirectory(), false);
+ files.sort(Comparator.comparing(f -> f.displayName().toLowerCase()));
+ return files;
+ }
+
+ private void collectPluginFiles(@Nonnull List out, @Nonnull Path directory, boolean enabled) {
+ if (!Files.isDirectory(directory))
+ return;
+ try (Stream stream = Files.list(directory)) {
+ stream.filter(path -> Files.isRegularFile(path) && path.toString().toLowerCase().endsWith(".jar"))
+ .forEach(path -> {
+ PluginInfo info = readPluginInfo(path);
+ if (info != null)
+ out.add(new LocalPluginFile(path, info, enabled));
+ });
+ } catch (IOException ex) {
+ logger.error("Failed to scan plugin directory: {}", directory, ex);
+ }
+ }
+
+ /**
+ * @param path
+ * Path to a candidate plugin jar.
+ *
+ * @return Parsed plugin information, or {@code null} if the file is not a valid plugin.
+ */
+ @Nullable
+ private PluginInfo readPluginInfo(@Nonnull Path path) {
+ try {
+ PreparedPlugin prepared = infoLoader.prepare(ByteSources.forPath(path));
+ if (prepared == null)
+ return null;
+ PluginInfo info = prepared.info();
+ // Release the file handle, we only wanted the plugin information.
+ prepared.reject();
+ return info;
+ } catch (PluginException ex) {
+ logger.warn("Skipping invalid plugin file: {}", path, ex);
+ return null;
+ }
+ }
+
+ /**
+ * Prompts the user for a plugin jar, then copies it into the plugin directory and loads it.
+ */
+ private void installPlugin() {
+ FileChooser chooser = new FileChooserBuilder()
+ .setTitle(Lang.get("menu.plugin.install"))
+ .setFileExtensionFilter("Java archives", "*.jar")
+ .build();
+ File selected = chooser.showOpenDialog(getScene().getWindow());
+ if (selected == null)
+ return;
+ Path source = selected.toPath();
+ ThreadUtil.run(() -> {
+ // Validate before copying: reject non-plugins and duplicates with a targeted message.
+ PluginInfo info = readPluginInfo(source);
+ if (info == null) {
+ FxThreadUtil.run(() -> ErrorDialogs.show(getBinding("menu.plugin.error.install"),
+ getBinding("menu.plugin.install"),
+ getBinding("menu.plugin.install.invalid"),
+ new PluginException("Not a valid plugin: " + source.getFileName())));
+ return;
+ }
+ if (pluginManager.isPluginLoaded(info.id())) {
+ FxThreadUtil.run(() -> ErrorDialogs.show(getBinding("menu.plugin.error.install"),
+ getBinding("menu.plugin.install"),
+ getBinding("menu.plugin.install.duplicate"),
+ new PluginException("Duplicate plugin id: " + info.id())));
+ return;
+ }
+ try {
+ installFrom(source);
+ } catch (IOException | PluginException ex) {
+ logger.error("Failed to install plugin: {}", source, ex);
+ FxThreadUtil.run(() -> ErrorDialogs.show(getBinding("menu.plugin.error.install"),
+ getBinding("menu.plugin.install"),
+ getBinding("menu.plugin.error.load"), ex));
+ }
+ refresh();
+ });
+ }
+
+ /**
+ * Copies a plugin jar into the plugin directory and loads it. Synchronous, no UI.
+ * On load failure the copied file is removed and the error is rethrown.
+ *
+ * @param source
+ * Path to the plugin jar to install.
+ *
+ * @throws IOException
+ * If the file could not be copied.
+ * @throws PluginException
+ * If the copied plugin could not be loaded.
+ */
+ void installFrom(@Nonnull Path source) throws IOException, PluginException {
+ Path destination = directories.getPluginDirectory().resolve(source.getFileName().toString());
+ try {
+ Files.copy(source, destination, StandardCopyOption.REPLACE_EXISTING);
+ pluginManager.loadPlugins(singleFileDiscoverer(destination));
+ } catch (IOException | PluginException ex) {
+ try {
+ Files.deleteIfExists(destination);
+ } catch (IOException ignored) {
+ // Keeping the file is not harmful, it failed to load anyway.
+ }
+ throw ex;
+ }
+ }
+
+ /**
+ * Enables or disables the given plugin file.
+ * Enabling moves the file into the plugin directory and loads it.
+ * Disabling unloads the plugin (plus any dependants) and moves the file into the disabled directory.
+ *
+ * @param file
+ * Plugin file to update.
+ * @param enable
+ * {@code true} to enable, {@code false} to disable.
+ */
+ private void setPluginEnabled(@Nonnull LocalPluginFile file, boolean enable) {
+ if (enable) {
+ ThreadUtil.run(() -> {
+ try {
+ applyEnabled(file, true);
+ } catch (IOException | PluginException ex) {
+ logger.error("Failed to enable plugin: {}", file.info().id(), ex);
+ FxThreadUtil.run(() -> ErrorDialogs.show(getBinding("menu.plugin.error"),
+ literalBinding(file.displayName()),
+ getBinding("menu.plugin.error.load"), ex));
+ }
+ refresh();
+ });
+ } else {
+ // Warn about dependant plugins that will be unloaded alongside this one.
+ List dependants = dependantNames(file.info().id());
+ if (!dependants.isEmpty() && !confirm(Lang.get("menu.plugin.enabled"),
+ Lang.get("menu.plugin.uninstall.dependants") + "\n - " + String.join("\n - ", dependants))) {
+ return;
+ }
+ ThreadUtil.run(() -> {
+ try {
+ applyEnabled(file, false);
+ } catch (IOException | PluginException ex) {
+ logger.error("Failed to disable plugin: {}", file.info().id(), ex);
+ FxThreadUtil.run(() -> ErrorDialogs.show(getBinding("menu.plugin.error"),
+ literalBinding(file.displayName()),
+ getBinding("menu.plugin.error.unload"), ex));
+ }
+ refresh();
+ });
+ }
+ }
+
+ /**
+ * Applies an enable/disable state change to a plugin file. Synchronous, no UI.
+ *
+ * - Enabling moves the file into the plugin directory and loads it if not already loaded.
+ * - Disabling unloads the plugin then moves the file into the disabled directory.
+ *
+ * On enable failure the file is moved back to its original location before the error is rethrown.
+ *
+ * @param file
+ * Plugin file to update.
+ * @param enable
+ * {@code true} to enable, {@code false} to disable.
+ *
+ * @throws IOException
+ * If the file could not be moved.
+ * @throws PluginException
+ * If the plugin could not be loaded or unloaded.
+ */
+ void applyEnabled(@Nonnull LocalPluginFile file, boolean enable) throws IOException, PluginException {
+ if (enable) {
+ Path destination = directories.getPluginDirectory().resolve(file.path().getFileName().toString());
+ try {
+ Files.move(file.path(), destination, StandardCopyOption.REPLACE_EXISTING);
+ if (!pluginManager.isPluginLoaded(file.info().id()))
+ pluginManager.loadPlugins(singleFileDiscoverer(destination));
+ } catch (IOException | PluginException ex) {
+ // Move the file back so the on-disk state matches the failed load.
+ try {
+ Files.move(destination, file.path(), StandardCopyOption.REPLACE_EXISTING);
+ } catch (IOException ignored) {}
+ throw ex;
+ }
+ } else {
+ unloadIfLoaded(file.info().id());
+ Path disabledDirectory = directories.getDisabledPluginDirectory();
+ Files.createDirectories(disabledDirectory);
+ Files.move(file.path(), disabledDirectory.resolve(file.path().getFileName().toString()),
+ StandardCopyOption.REPLACE_EXISTING);
+ }
+ }
+
+ /**
+ * Uninstalls the given plugin after user confirmation, unloading it first if necessary.
+ *
+ * @param file
+ * Plugin file to remove.
+ */
+ private void uninstallPlugin(@Nonnull LocalPluginFile file) {
+ List dependants = file.enabled() ? dependantNames(file.info().id()) : List.of();
+ String content = Lang.get("menu.plugin.uninstall.warning");
+ if (!dependants.isEmpty())
+ content += "\n" + Lang.get("menu.plugin.uninstall.dependants") + "\n - " + String.join("\n - ", dependants);
+ if (!confirm(Lang.get("menu.plugin.uninstall"), content))
+ return;
+ ThreadUtil.run(() -> {
+ try {
+ applyUninstall(file);
+ } catch (IOException | PluginException ex) {
+ logger.error("Failed to uninstall plugin: {}", file.info().id(), ex);
+ FxThreadUtil.run(() -> ErrorDialogs.show(getBinding("menu.plugin.error"),
+ literalBinding(file.displayName()),
+ getBinding("menu.plugin.error.uninstall"), ex));
+ }
+ refresh();
+ });
+ }
+
+ /**
+ * Unloads the plugin (if loaded) and deletes its jar file. Synchronous, no UI.
+ *
+ * @param file
+ * Plugin file to remove.
+ *
+ * @throws IOException
+ * If the file could not be deleted.
+ * @throws PluginException
+ * If the plugin could not be unloaded.
+ */
+ void applyUninstall(@Nonnull LocalPluginFile file) throws IOException, PluginException {
+ unloadIfLoaded(file.info().id());
+ Files.deleteIfExists(file.path());
+ }
+
+ private void unloadIfLoaded(@Nonnull String id) throws PluginException {
+ if (pluginManager.isPluginLoaded(id))
+ pluginManager.unloaderFor(id).commit();
+ }
+
+ /**
+ * @param id
+ * Plugin identifier.
+ *
+ * @return Names of loaded plugins depending on the given plugin.
+ */
+ @Nonnull
+ private List dependantNames(@Nonnull String id) {
+ if (!pluginManager.isPluginLoaded(id))
+ return List.of();
+ PluginUnloader unloader = pluginManager.unloaderFor(id);
+ return unloader.dependants()
+ .map(info -> info.name().isBlank() ? info.id() : info.name())
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * Opens the local plugins directory.
+ */
+ private void browse() {
+ try {
+ DesktopUtil.showDocument(directories.getPluginDirectory().toUri());
+ } catch (IOException ex) {
+ logger.error("Failed to show plugins directory", ex);
+ }
+ }
+
+ private boolean confirm(@Nonnull String title, @Nonnull String content) {
+ Alert alert = new Alert(Alert.AlertType.CONFIRMATION, content, ButtonType.YES, ButtonType.NO);
+ alert.setTitle(title);
+ javafx.stage.Stage stage = (javafx.stage.Stage) alert.getDialogPane().getScene().getWindow();
+ stage.getIcons().add(Icons.getImage(Icons.LOGO));
+ return alert.showAndWait().orElse(ButtonType.NO) == ButtonType.YES;
+ }
+
+ @Nonnull
+ private static PluginDiscoverer singleFileDiscoverer(@Nonnull Path path) {
+ return new PathPluginDiscoverer() {
+ @Nonnull
+ @Override
+ protected Stream stream() {
+ return Stream.of(path);
+ }
+ };
+ }
+
+ @Nonnull
+ private static StringBinding literalBinding(@Nonnull String text) {
+ return new StringBinding() {
+ @Override
+ protected String computeValue() {
+ return text;
+ }
+ };
+ }
+
+ /**
+ * Model of a plugin jar on disk.
+ *
+ * @param path
+ * Path to the plugin jar.
+ * @param info
+ * Parsed plugin information.
+ * @param enabled
+ * {@code true} when the file resides in the scanned plugin directory.
+ */
+ record LocalPluginFile(@Nonnull Path path, @Nonnull PluginInfo info, boolean enabled) {
+ @Nonnull
+ String displayName() {
+ return info.name().isBlank() ? info.id() : info.name();
+ }
+ }
+
+ /**
+ * Entry showing the plugin details + enable/uninstall actions.
+ */
+ private class PluginEntry extends BorderPane {
+ private PluginEntry(@Nonnull LocalPluginFile file) {
+ setPadding(new Insets(10));
+ getStyleClass().add("tooltip");
+
+ PluginInfo info = file.info();
+ Label nameLabel = new Label(file.displayName());
+ nameLabel.setWrapText(true);
+ nameLabel.setMinSize(350, 20);
+ nameLabel.setMaxWidth(550);
+ nameLabel.getStyleClass().add(Styles.TITLE_3);
+
+ VBox infoBox = new VBox();
+ infoBox.getChildren().add(nameLabel);
+ if (!info.description().isBlank())
+ infoBox.getChildren().add(makeAttribLabel(null, info.description()));
+ if (!info.author().isBlank())
+ infoBox.getChildren().add(makeAttribLabel(getBinding("menu.plugin.author"), info.author()));
+ if (!info.version().isBlank())
+ infoBox.getChildren().add(makeAttribLabel(getBinding("menu.plugin.version"), info.version()));
+ if (!info.dependencies().isEmpty())
+ infoBox.getChildren().add(makeAttribLabel(getBinding("menu.plugin.dependencies"),
+ String.join(", ", info.dependencies())));
+
+ CheckBox enabledCheck = new CheckBox();
+ enabledCheck.textProperty().bind(getBinding("menu.plugin.enabled"));
+ enabledCheck.setSelected(file.enabled());
+ enabledCheck.selectedProperty().addListener((ob, old, cur) -> setPluginEnabled(file, cur));
+
+ ActionButton uninstallButton = new ActionButton(CarbonIcons.TRASH_CAN,
+ getBinding("menu.plugin.uninstall"), () -> uninstallPlugin(file));
+ uninstallButton.setAlignment(Pos.CENTER_LEFT);
+ uninstallButton.setPrefSize(130, 30);
+
+ VBox actions = new VBox();
+ actions.setSpacing(8);
+ actions.setAlignment(Pos.CENTER_RIGHT);
+ actions.getChildren().addAll(enabledCheck, uninstallButton);
+
+ setLeft(infoBox);
+ setRight(actions);
+
+ prefWidthProperty().bind(widthProperty());
+ }
+
+ /**
+ * Used to display bullet point format.
+ *
+ * @param langBinding
+ * Language binding for label display.
+ * @param secondaryText
+ * Text to appear after the initial binding text.
+ *
+ * @return Label bound to translatable text.
+ */
+ @Nonnull
+ private static Label makeAttribLabel(@Nullable StringBinding langBinding, @Nonnull String secondaryText) {
+ Label label = new Label(secondaryText);
+ label.setWrapText(true);
+ label.setMaxWidth(550);
+ if (langBinding != null) {
+ label.textProperty().bind(new StringBinding() {
+ {
+ bind(langBinding);
+ }
+
+ @Override
+ protected String computeValue() {
+ return String.format(" • %s: %s", langBinding.get(), secondaryText);
+ }
+ });
+ }
+ return label;
+ }
+ }
+}
diff --git a/recaf-ui/src/main/java/software/coley/recaf/ui/window/PluginManagerWindow.java b/recaf-ui/src/main/java/software/coley/recaf/ui/window/PluginManagerWindow.java
new file mode 100644
index 000000000..11d0d09e4
--- /dev/null
+++ b/recaf-ui/src/main/java/software/coley/recaf/ui/window/PluginManagerWindow.java
@@ -0,0 +1,28 @@
+package software.coley.recaf.ui.window;
+
+import jakarta.enterprise.context.Dependent;
+import jakarta.inject.Inject;
+import javafx.scene.layout.BorderPane;
+import software.coley.recaf.services.window.WindowManager;
+import software.coley.recaf.ui.pane.PluginManagerPane;
+import software.coley.recaf.util.Lang;
+
+/**
+ * Window wrapper for {@link PluginManagerPane}.
+ *
+ * @author Canrad
+ * @see PluginManagerPane
+ */
+@Dependent
+public class PluginManagerWindow extends AbstractIdentifiableStage {
+ @Inject
+ public PluginManagerWindow(PluginManagerPane pluginManagerPane) {
+ super(WindowManager.WIN_PLUGINS);
+
+ // Layout
+ titleProperty().bind(Lang.getBinding("menu.plugin.manage"));
+ setMinWidth(750);
+ setMinHeight(450);
+ setScene(new RecafScene(new BorderPane(pluginManagerPane), 750, 450));
+ }
+}
diff --git a/recaf-ui/src/main/resources/translations/en_US.lang b/recaf-ui/src/main/resources/translations/en_US.lang
index bd135ebfe..ee27f3609 100644
--- a/recaf-ui/src/main/resources/translations/en_US.lang
+++ b/recaf-ui/src/main/resources/translations/en_US.lang
@@ -212,6 +212,22 @@ menu.plugin.browse=Browse plugins
menu.plugin.enabled=Enabled
menu.plugin.uninstall=Uninstall
menu.plugin.uninstall.warning=Are you sure you want to delete this plugin?
+menu.plugin.uninstall.dependants=Plugins that depend on it will also be unloaded:
+menu.plugin.install=Install plugin
+menu.plugin.install.invalid=The selected file is not a valid Recaf plugin
+menu.plugin.install.duplicate=A plugin with the same ID is already installed
+menu.plugin.none-found=No plugins installed
+menu.plugin.author=Author
+menu.plugin.version=Version
+menu.plugin.dependencies=Dependencies
+menu.plugin.refresh=Refresh
+menu.plugin.devguide=Plugin development guide
+menu.plugin.template=Plugin template workspace
+menu.plugin.error=Plugin error
+menu.plugin.error.load=Failed to load plugin
+menu.plugin.error.unload=Failed to unload plugin
+menu.plugin.error.install=Failed to install plugin
+menu.plugin.error.uninstall=Failed to uninstall plugin
##### Keybinds
bind.inputprompt.initial=
diff --git a/recaf-ui/src/main/resources/translations/zh_CN.lang b/recaf-ui/src/main/resources/translations/zh_CN.lang
index f0ee67a03..689b6baa0 100644
--- a/recaf-ui/src/main/resources/translations/zh_CN.lang
+++ b/recaf-ui/src/main/resources/translations/zh_CN.lang
@@ -141,6 +141,22 @@ menu.plugin.browse=浏览插件
menu.plugin.enabled=启用
menu.plugin.uninstall=卸载
menu.plugin.uninstall.warning=你确定要删除该插件吗?
+menu.plugin.uninstall.dependants=依赖它的插件也将被一并停用:
+menu.plugin.install=安装插件
+menu.plugin.install.invalid=所选文件不是有效的 Recaf 插件
+menu.plugin.install.duplicate=已安装相同 ID 的插件
+menu.plugin.none-found=未安装任何插件
+menu.plugin.author=作者
+menu.plugin.version=版本
+menu.plugin.dependencies=依赖
+menu.plugin.refresh=刷新
+menu.plugin.devguide=插件开发指南
+menu.plugin.template=插件模板工程
+menu.plugin.error=插件错误
+menu.plugin.error.load=插件加载失败
+menu.plugin.error.unload=插件停用失败
+menu.plugin.error.install=插件安装失败
+menu.plugin.error.uninstall=插件删除失败
##### Keybinds
bind.inputprompt.initial=<等待中>
diff --git a/recaf-ui/src/test/java/software/coley/recaf/ui/pane/PluginManagerPaneTest.java b/recaf-ui/src/test/java/software/coley/recaf/ui/pane/PluginManagerPaneTest.java
new file mode 100644
index 000000000..f1758d6af
--- /dev/null
+++ b/recaf-ui/src/test/java/software/coley/recaf/ui/pane/PluginManagerPaneTest.java
@@ -0,0 +1,284 @@
+package software.coley.recaf.ui.pane;
+
+import jakarta.annotation.Nonnull;
+import jakarta.annotation.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import software.coley.recaf.plugin.Plugin;
+import software.coley.recaf.plugin.PluginInformation;
+import software.coley.recaf.services.ServiceConfig;
+import software.coley.recaf.services.file.RecafDirectoriesConfig;
+import software.coley.recaf.services.plugin.ClassAllocator;
+import software.coley.recaf.services.plugin.PluginContainer;
+import software.coley.recaf.services.plugin.PluginException;
+import software.coley.recaf.services.plugin.PluginInfo;
+import software.coley.recaf.services.plugin.PluginLoader;
+import software.coley.recaf.services.plugin.PluginManager;
+import software.coley.recaf.services.plugin.PluginUnloader;
+import software.coley.recaf.services.plugin.discovery.DiscoveredPluginSource;
+import software.coley.recaf.services.plugin.discovery.PluginDiscoverer;
+import software.coley.recaf.services.plugin.zip.ZipPluginLoader;
+import software.coley.recaf.ui.BaseFxTest;
+import software.coley.recaf.ui.pane.PluginManagerPane.LocalPluginFile;
+import software.coley.recaf.ui.pane.sample.SampleAlphaPlugin;
+import software.coley.recaf.ui.pane.sample.SampleBetaPlugin;
+import software.coley.recaf.util.io.ByteSource;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Tests for {@link PluginManagerPane} plugin file management: scanning, enable/disable, install, uninstall.
+ *
+ * These tests exercise only the synchronous business logic of the pane,
+ * bypassing JavaFX UI initialization via the package-private testing constructor.
+ *
+ * @author Canrad
+ */
+class PluginManagerPaneTest extends BaseFxTest {
+ private Path home;
+ private Path pluginDir;
+ private Path disabledDir;
+ private Path stagingDir;
+ private FakePluginManager manager;
+ private PluginManagerPane pane;
+
+ @BeforeEach
+ void setup() throws Exception {
+ // Fresh directories per test. Windows keeps memory-mapped plugin jars locked until GC,
+ // so isolating each test's files avoids cross-test delete/move conflicts.
+ home = Files.createTempDirectory("recaf-plugin-pane-test");
+ pluginDir = Files.createDirectories(home.resolve("plugins"));
+ disabledDir = Files.createDirectories(pluginDir.resolve("disabled"));
+ stagingDir = Files.createDirectories(home.resolve("staging"));
+
+ RecafDirectoriesConfig dirs = mock(RecafDirectoriesConfig.class);
+ when(dirs.getPluginDirectory()).thenReturn(pluginDir);
+ when(dirs.getDisabledPluginDirectory()).thenReturn(disabledDir);
+
+ manager = new FakePluginManager();
+ pane = new PluginManagerPane(manager, dirs, true);
+ }
+
+ @AfterEach
+ void cleanup() throws Exception {
+ if (home != null) {
+ try (var walk = Files.walk(home)) {
+ walk.sorted(java.util.Comparator.reverseOrder())
+ .forEach(p -> {
+ try {
+ Files.deleteIfExists(p);
+ } catch (IOException ignored) {}
+ });
+ }
+ }
+ }
+
+ @Test
+ void testScanFindsEnabledAndDisabled() throws Exception {
+ writePluginJar(pluginDir.resolve("alpha.jar"), SampleAlphaPlugin.class);
+ writePluginJar(disabledDir.resolve("beta.jar"), SampleBetaPlugin.class);
+
+ List files = pane.scanPluginFiles();
+ assertEquals(2, files.size(), "Should find both the enabled and disabled plugin");
+
+ LocalPluginFile alpha = find(files, "sample-alpha");
+ LocalPluginFile beta = find(files, "sample-beta");
+ assertTrue(alpha.enabled(), "Plugin in the plugin directory should be enabled");
+ assertFalse(beta.enabled(), "Plugin in the disabled directory should be disabled");
+ assertEquals("Sample Alpha", alpha.info().name());
+ assertEquals("Sample Beta", beta.info().name());
+ }
+
+ @Test
+ void testDisableMovesToDisabledAndUnloads() throws Exception {
+ Path jar = pluginDir.resolve("alpha.jar");
+ writePluginJar(jar, SampleAlphaPlugin.class);
+ manager.loaded.add("sample-alpha");
+ LocalPluginFile alpha = entry(jar, SampleAlphaPlugin.class, true);
+
+ pane.applyEnabled(alpha, false);
+
+ assertFalse(Files.exists(jar), "Jar should no longer be in the enabled directory");
+ assertTrue(Files.exists(disabledDir.resolve("alpha.jar")), "Jar should be moved to the disabled directory");
+ assertFalse(manager.isPluginLoaded("sample-alpha"), "Plugin should be unloaded after disabling");
+ }
+
+ @Test
+ void testEnableMovesBackAndLoads() throws Exception {
+ Path disabledJar = disabledDir.resolve("alpha.jar");
+ writePluginJar(disabledJar, SampleAlphaPlugin.class);
+ LocalPluginFile alpha = entry(disabledJar, SampleAlphaPlugin.class, false);
+
+ pane.applyEnabled(alpha, true);
+
+ assertFalse(Files.exists(disabledJar), "Jar should no longer be in the disabled directory");
+ assertTrue(Files.exists(pluginDir.resolve("alpha.jar")), "Jar should be moved to the enabled directory");
+ assertTrue(manager.isPluginLoaded("sample-alpha"), "Plugin should be loaded after enabling");
+ }
+
+ @Test
+ void testInstallCopiesAndLoads() throws Exception {
+ Path source = stagingDir.resolve("alpha.jar");
+ writePluginJar(source, SampleAlphaPlugin.class);
+
+ pane.installFrom(source);
+
+ assertTrue(Files.exists(source), "Source jar should be left untouched");
+ assertTrue(Files.exists(pluginDir.resolve("alpha.jar")), "Jar should be copied into the plugin directory");
+ assertTrue(manager.isPluginLoaded("sample-alpha"), "Plugin should be loaded after install");
+ }
+
+ @Test
+ void testUninstallDeletesAndUnloads() throws Exception {
+ Path jar = pluginDir.resolve("alpha.jar");
+ writePluginJar(jar, SampleAlphaPlugin.class);
+ manager.loaded.add("sample-alpha");
+ LocalPluginFile alpha = entry(jar, SampleAlphaPlugin.class, true);
+
+ pane.applyUninstall(alpha);
+
+ assertFalse(Files.exists(jar), "Jar should be deleted after uninstall");
+ assertFalse(manager.isPluginLoaded("sample-alpha"), "Plugin should be unloaded after uninstall");
+ }
+
+ @Nonnull
+ private static LocalPluginFile find(@Nonnull List files, @Nonnull String id) {
+ return files.stream().filter(f -> f.info().id().equals(id)).findFirst()
+ .orElseThrow(() -> new AssertionError("No plugin file with id: " + id));
+ }
+
+ /**
+ * Builds a {@link LocalPluginFile} directly from the plugin's annotation, without memory-mapping the jar.
+ * This avoids leaving a Windows file lock on a jar that the test is about to move or delete.
+ */
+ @Nonnull
+ private static LocalPluginFile entry(@Nonnull Path path, @Nonnull Class extends Plugin> cls, boolean enabled) {
+ PluginInformation a = cls.getAnnotation(PluginInformation.class);
+ PluginInfo info = new PluginInfo(a.id(), a.name(), a.version(), a.author(), a.description(),
+ Set.of(a.dependencies()), Set.of(a.softDependencies()));
+ return new LocalPluginFile(path, info, enabled);
+ }
+
+ /**
+ * Packages a compiled plugin class into a valid plugin jar (class bytes + service descriptor).
+ */
+ private static void writePluginJar(@Nonnull Path jarPath, @Nonnull Class extends Plugin> pluginClass) throws IOException {
+ String classResource = pluginClass.getName().replace('.', '/') + ".class";
+ byte[] classBytes;
+ try (var in = PluginManagerPaneTest.class.getClassLoader().getResourceAsStream(classResource)) {
+ assertNotNull(in, "Missing compiled class: " + classResource);
+ classBytes = in.readAllBytes();
+ }
+ try (OutputStream fos = Files.newOutputStream(jarPath);
+ ZipOutputStream zos = new ZipOutputStream(fos)) {
+ zos.putNextEntry(new ZipEntry(classResource));
+ zos.write(classBytes);
+ zos.closeEntry();
+ zos.putNextEntry(new ZipEntry("META-INF/services/" + Plugin.class.getName()));
+ zos.write(pluginClass.getName().getBytes());
+ zos.closeEntry();
+ }
+ }
+
+ /**
+ * Functional {@link PluginManager} that tracks loaded plugin ids by parsing jars with {@link ZipPluginLoader},
+ * so {@link #isPluginLoaded} reflects the real load/unload effects triggered by the pane.
+ */
+ private static class FakePluginManager implements PluginManager {
+ private final Set loaded = new HashSet<>();
+ private final ZipPluginLoader loader = new ZipPluginLoader();
+
+ @Nonnull
+ @Override
+ public Collection> loadPlugins(@Nonnull PluginDiscoverer discoverer) throws PluginException {
+ for (DiscoveredPluginSource source : discoverer.findSources()) {
+ ByteSource bytes = source.source();
+ var prepared = loader.prepare(bytes);
+ if (prepared != null) {
+ loaded.add(prepared.info().id());
+ prepared.reject();
+ }
+ }
+ return List.of();
+ }
+
+ @Nonnull
+ @Override
+ public PluginUnloader unloaderFor(@Nonnull String id) {
+ return new PluginUnloader() {
+ @Override
+ public void commit() {
+ loaded.remove(id);
+ }
+
+ @Nonnull
+ @Override
+ public PluginInfo unloadingPlugin() {
+ return PluginInfo.empty().withId(id);
+ }
+
+ @Nonnull
+ @Override
+ public Stream dependants() {
+ return Stream.empty();
+ }
+ };
+ }
+
+ @Override
+ public boolean isPluginLoaded(@Nonnull String id) {
+ return loaded.contains(id);
+ }
+
+ @Nonnull
+ @Override
+ public ClassAllocator getAllocator() {
+ throw new UnsupportedOperationException();
+ }
+
+ @Nullable
+ @Override
+ public PluginContainer getPlugin(@Nonnull String id) {
+ return null;
+ }
+
+ @Nonnull
+ @Override
+ public Collection> getPlugins() {
+ return List.of();
+ }
+
+ @Override
+ public void registerLoader(@Nonnull PluginLoader loader) {}
+
+ @Nonnull
+ @Override
+ public String getServiceId() {
+ return "fake-plugin-manager";
+ }
+
+ @Nonnull
+ @Override
+ public ServiceConfig getServiceConfig() {
+ return mock(ServiceConfig.class);
+ }
+ }
+}
diff --git a/recaf-ui/src/test/java/software/coley/recaf/ui/pane/sample/SampleAlphaPlugin.java b/recaf-ui/src/test/java/software/coley/recaf/ui/pane/sample/SampleAlphaPlugin.java
new file mode 100644
index 000000000..e52544f44
--- /dev/null
+++ b/recaf-ui/src/test/java/software/coley/recaf/ui/pane/sample/SampleAlphaPlugin.java
@@ -0,0 +1,19 @@
+package software.coley.recaf.ui.pane.sample;
+
+import software.coley.recaf.plugin.Plugin;
+import software.coley.recaf.plugin.PluginInformation;
+
+/**
+ * Sample plugin used by {@code PluginManagerPaneTest} to build real plugin jars.
+ *
+ * @author Canrad
+ */
+@PluginInformation(id = "sample-alpha", name = "Sample Alpha", version = "1.0.0",
+ author = "tester", description = "First sample plugin.")
+public class SampleAlphaPlugin implements Plugin {
+ @Override
+ public void onEnable() {}
+
+ @Override
+ public void onDisable() {}
+}
diff --git a/recaf-ui/src/test/java/software/coley/recaf/ui/pane/sample/SampleBetaPlugin.java b/recaf-ui/src/test/java/software/coley/recaf/ui/pane/sample/SampleBetaPlugin.java
new file mode 100644
index 000000000..1f32c1f61
--- /dev/null
+++ b/recaf-ui/src/test/java/software/coley/recaf/ui/pane/sample/SampleBetaPlugin.java
@@ -0,0 +1,19 @@
+package software.coley.recaf.ui.pane.sample;
+
+import software.coley.recaf.plugin.Plugin;
+import software.coley.recaf.plugin.PluginInformation;
+
+/**
+ * Sample plugin used by {@code PluginManagerPaneTest} to build real plugin jars.
+ *
+ * @author Canrad
+ */
+@PluginInformation(id = "sample-beta", name = "Sample Beta", version = "2.1.0",
+ author = "tester", description = "Second sample plugin.")
+public class SampleBetaPlugin implements Plugin {
+ @Override
+ public void onEnable() {}
+
+ @Override
+ public void onDisable() {}
+}