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. + *

+ * 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 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 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() {} +}