From 7131c0361e60d632f9a8105ca655685ecf609543 Mon Sep 17 00:00:00 2001 From: Canrad <1517807724@qq.com> Date: Sun, 26 Jul 2026 20:00:55 +0800 Subject: [PATCH 1/3] Add plugin manager UI Add a Plugins menu and plugin manager panel for listing installed plugins and enabling/disabling, installing, and uninstalling them. - PluginManagerPane: scans the plugin + disabled directories, renders plugin metadata, and drives install/enable/disable/uninstall via synchronous core operations (installFrom / applyEnabled / applyUninstall). - PluginManagerWindow + WindowManager WIN_PLUGINS registration. - PluginMenu: opens the manager panel, plus dev-guide and template links. - Translations for en_US and zh_CN. - PluginManagerPaneTest covering scan/enable/disable/install/uninstall with real sample plugin jars. --- .../recaf/services/window/WindowManager.java | 9 + .../coley/recaf/ui/menubar/MainMenu.java | 13 +- .../recaf/ui/menubar/MainMenuProvider.java | 3 +- .../coley/recaf/ui/menubar/PluginMenu.java | 75 +++ .../recaf/ui/pane/PluginManagerPane.java | 536 ++++++++++++++++++ .../recaf/ui/window/PluginManagerWindow.java | 28 + .../main/resources/translations/en_US.lang | 16 + .../main/resources/translations/zh_CN.lang | 16 + .../recaf/ui/pane/PluginManagerPaneTest.java | 312 ++++++++++ .../ui/pane/sample/SampleAlphaPlugin.java | 19 + .../ui/pane/sample/SampleBetaPlugin.java | 19 + 11 files changed, 1044 insertions(+), 2 deletions(-) create mode 100644 recaf-ui/src/main/java/software/coley/recaf/ui/menubar/PluginMenu.java create mode 100644 recaf-ui/src/main/java/software/coley/recaf/ui/pane/PluginManagerPane.java create mode 100644 recaf-ui/src/main/java/software/coley/recaf/ui/window/PluginManagerWindow.java create mode 100644 recaf-ui/src/test/java/software/coley/recaf/ui/pane/PluginManagerPaneTest.java create mode 100644 recaf-ui/src/test/java/software/coley/recaf/ui/pane/sample/SampleAlphaPlugin.java create mode 100644 recaf-ui/src/test/java/software/coley/recaf/ui/pane/sample/SampleBetaPlugin.java 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..47c3125e0 --- /dev/null +++ b/recaf-ui/src/main/java/software/coley/recaf/ui/pane/PluginManagerPane.java @@ -0,0 +1,536 @@ +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 = pluginManager; + this.directories = directories; + + 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) { + 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())) { + 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); + 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); + 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))) { + refresh(); + return; + } + ThreadUtil.run(() -> { + try { + applyEnabled(file, false); + } catch (IOException | PluginException ex) { + logger.error("Failed to disable plugin: {}", file.info().id(), ex); + 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); + 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..342b68c2f --- /dev/null +++ b/recaf-ui/src/test/java/software/coley/recaf/ui/pane/PluginManagerPaneTest.java @@ -0,0 +1,312 @@ +package software.coley.recaf.ui.pane; + +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import javafx.application.Platform; +import org.junit.jupiter.api.BeforeAll; +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.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +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. + *

+ * The pane's dialog/async wrappers are not exercised here; the synchronous core operations + * ({@link PluginManagerPane#installFrom}, {@link PluginManagerPane#applyEnabled}, + * {@link PluginManagerPane#applyUninstall}) are called directly against real plugin jars built from the + * {@link SampleAlphaPlugin}/{@link SampleBetaPlugin} sample classes. + * + * @author Canrad + */ +class PluginManagerPaneTest extends BaseFxTest { + private Path pluginDir; + private Path disabledDir; + private Path stagingDir; + private FakePluginManager manager; + private PluginManagerPane pane; + + @BeforeAll + static void startFx() { + try { + CountDownLatch started = new CountDownLatch(1); + Platform.startup(started::countDown); + started.await(10, TimeUnit.SECONDS); + } catch (IllegalStateException alreadyRunning) { + // Toolkit already up, fine. + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + + @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. + Path 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 = onFx(() -> new PluginManagerPane(manager, dirs)); + } + + @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(); + } + } + + /** + * Runs a supplier on the FX thread and returns its result. + */ + private static T onFx(@Nonnull java.util.function.Supplier supplier) throws InterruptedException { + if (Platform.isFxApplicationThread()) + return supplier.get(); + AtomicReference ref = new AtomicReference<>(); + AtomicReference err = new AtomicReference<>(); + CountDownLatch latch = new CountDownLatch(1); + Platform.runLater(() -> { + try { + ref.set(supplier.get()); + } catch (RuntimeException ex) { + err.set(ex); + } finally { + latch.countDown(); + } + }); + latch.await(10, TimeUnit.SECONDS); + if (err.get() != null) + throw err.get(); + return ref.get(); + } + + /** + * 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() {} +} From eee4cd4c10b9106b617f9a978f6edf3681af1f68 Mon Sep 17 00:00:00 2001 From: Canrad <1517807724@qq.com> Date: Mon, 27 Jul 2026 11:12:12 +0800 Subject: [PATCH 2/3] Fix CI timeout: add Platform.exit() in @AfterAll and clean up temp dirs - Add @AfterAll stopFx() with Platform.exit() to shut down JavaFX after tests complete, preventing JVM from hanging - Add @AfterEach cleanup() to delete temporary test directories - Track home path as field for cleanup - Wrap ErrorDialogs.show() calls with FxThreadUtil.run() to avoid IllegalStateException from background threads - Remove redundant refresh() call in dependant cancel path --- .../recaf/ui/pane/PluginManagerPane.java | 25 +++++++++---------- .../recaf/ui/pane/PluginManagerPaneTest.java | 24 +++++++++++++++++- 2 files changed, 35 insertions(+), 14 deletions(-) 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 index 47c3125e0..f23aef6b8 100644 --- 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 @@ -197,26 +197,26 @@ private void installPlugin() { // Validate before copying: reject non-plugins and duplicates with a targeted message. PluginInfo info = readPluginInfo(source); if (info == null) { - ErrorDialogs.show(getBinding("menu.plugin.error.install"), + 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())); + new PluginException("Not a valid plugin: " + source.getFileName()))); return; } if (pluginManager.isPluginLoaded(info.id())) { - ErrorDialogs.show(getBinding("menu.plugin.error.install"), + 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())); + new PluginException("Duplicate plugin id: " + info.id()))); return; } try { installFrom(source); } catch (IOException | PluginException ex) { logger.error("Failed to install plugin: {}", source, ex); - ErrorDialogs.show(getBinding("menu.plugin.error.install"), + FxThreadUtil.run(() -> ErrorDialogs.show(getBinding("menu.plugin.error.install"), getBinding("menu.plugin.install"), - getBinding("menu.plugin.error.load"), ex); + getBinding("menu.plugin.error.load"), ex)); } refresh(); }); @@ -266,9 +266,9 @@ private void setPluginEnabled(@Nonnull LocalPluginFile file, boolean enable) { applyEnabled(file, true); } catch (IOException | PluginException ex) { logger.error("Failed to enable plugin: {}", file.info().id(), ex); - ErrorDialogs.show(getBinding("menu.plugin.error"), + FxThreadUtil.run(() -> ErrorDialogs.show(getBinding("menu.plugin.error"), literalBinding(file.displayName()), - getBinding("menu.plugin.error.load"), ex); + getBinding("menu.plugin.error.load"), ex)); } refresh(); }); @@ -277,7 +277,6 @@ private void setPluginEnabled(@Nonnull LocalPluginFile file, boolean enable) { 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))) { - refresh(); return; } ThreadUtil.run(() -> { @@ -285,9 +284,9 @@ private void setPluginEnabled(@Nonnull LocalPluginFile file, boolean enable) { applyEnabled(file, false); } catch (IOException | PluginException ex) { logger.error("Failed to disable plugin: {}", file.info().id(), ex); - ErrorDialogs.show(getBinding("menu.plugin.error"), + FxThreadUtil.run(() -> ErrorDialogs.show(getBinding("menu.plugin.error"), literalBinding(file.displayName()), - getBinding("menu.plugin.error.unload"), ex); + getBinding("menu.plugin.error.unload"), ex)); } refresh(); }); @@ -353,9 +352,9 @@ private void uninstallPlugin(@Nonnull LocalPluginFile file) { applyUninstall(file); } catch (IOException | PluginException ex) { logger.error("Failed to uninstall plugin: {}", file.info().id(), ex); - ErrorDialogs.show(getBinding("menu.plugin.error"), + FxThreadUtil.run(() -> ErrorDialogs.show(getBinding("menu.plugin.error"), literalBinding(file.displayName()), - getBinding("menu.plugin.error.uninstall"), ex); + getBinding("menu.plugin.error.uninstall"), ex)); } refresh(); }); 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 index 342b68c2f..8c7d14767 100644 --- 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 @@ -3,6 +3,8 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; import javafx.application.Platform; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -59,6 +61,7 @@ * @author Canrad */ class PluginManagerPaneTest extends BaseFxTest { + private Path home; private Path pluginDir; private Path disabledDir; private Path stagingDir; @@ -78,11 +81,16 @@ static void startFx() { } } + @AfterAll + static void stopFx() { + Platform.exit(); + } + @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. - Path home = Files.createTempDirectory("recaf-plugin-pane-test"); + 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")); @@ -95,6 +103,20 @@ void setup() throws Exception { pane = onFx(() -> new PluginManagerPane(manager, dirs)); } + @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); From 8c5d5767aaaa33c975c948463d7de730e512b1e9 Mon Sep 17 00:00:00 2001 From: Canrad <1517807724@qq.com> Date: Mon, 27 Jul 2026 13:27:13 +0800 Subject: [PATCH 3/3] Fix PluginManagerPaneTest CI hang: remove JavaFX dependency entirely The test previously required JavaFX (Platform.startup/Platform.runLater) to construct PluginManagerPane, which caused CI timeout under JUnit 5 parallel execution (all test classes share one FX thread). Added a package-private constructor PluginManagerPane(manager, dirs, true) that skips all UI initialization. The test now constructs the pane directly without JavaFX, exercising only the synchronous business logic (scanPluginFiles, applyEnabled, installFrom, applyUninstall). Also already applied: ErrorDialogs.show() wrapped via FxThreadUtil.run() to run on FX thread; @AfterEach temp dir cleanup. --- .../recaf/ui/pane/PluginManagerPane.java | 8 +++ .../recaf/ui/pane/PluginManagerPaneTest.java | 56 +------------------ 2 files changed, 11 insertions(+), 53 deletions(-) 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 index f23aef6b8..cc9f2df23 100644 --- 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 @@ -78,8 +78,16 @@ public class PluginManagerPane extends BorderPane { @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); 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 index 8c7d14767..f1758d6af 100644 --- 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 @@ -2,10 +2,7 @@ import jakarta.annotation.Nonnull; import jakarta.annotation.Nullable; -import javafx.application.Platform; -import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import software.coley.recaf.plugin.Plugin; @@ -36,9 +33,6 @@ import java.util.HashSet; import java.util.List; import java.util.Set; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -53,10 +47,8 @@ /** * Tests for {@link PluginManagerPane} plugin file management: scanning, enable/disable, install, uninstall. *

- * The pane's dialog/async wrappers are not exercised here; the synchronous core operations - * ({@link PluginManagerPane#installFrom}, {@link PluginManagerPane#applyEnabled}, - * {@link PluginManagerPane#applyUninstall}) are called directly against real plugin jars built from the - * {@link SampleAlphaPlugin}/{@link SampleBetaPlugin} sample classes. + * These tests exercise only the synchronous business logic of the pane, + * bypassing JavaFX UI initialization via the package-private testing constructor. * * @author Canrad */ @@ -68,24 +60,6 @@ class PluginManagerPaneTest extends BaseFxTest { private FakePluginManager manager; private PluginManagerPane pane; - @BeforeAll - static void startFx() { - try { - CountDownLatch started = new CountDownLatch(1); - Platform.startup(started::countDown); - started.await(10, TimeUnit.SECONDS); - } catch (IllegalStateException alreadyRunning) { - // Toolkit already up, fine. - } catch (InterruptedException ignored) { - Thread.currentThread().interrupt(); - } - } - - @AfterAll - static void stopFx() { - Platform.exit(); - } - @BeforeEach void setup() throws Exception { // Fresh directories per test. Windows keeps memory-mapped plugin jars locked until GC, @@ -100,7 +74,7 @@ void setup() throws Exception { when(dirs.getDisabledPluginDirectory()).thenReturn(disabledDir); manager = new FakePluginManager(); - pane = onFx(() -> new PluginManagerPane(manager, dirs)); + pane = new PluginManagerPane(manager, dirs, true); } @AfterEach @@ -224,30 +198,6 @@ private static void writePluginJar(@Nonnull Path jarPath, @Nonnull Class T onFx(@Nonnull java.util.function.Supplier supplier) throws InterruptedException { - if (Platform.isFxApplicationThread()) - return supplier.get(); - AtomicReference ref = new AtomicReference<>(); - AtomicReference err = new AtomicReference<>(); - CountDownLatch latch = new CountDownLatch(1); - Platform.runLater(() -> { - try { - ref.set(supplier.get()); - } catch (RuntimeException ex) { - err.set(ex); - } finally { - latch.countDown(); - } - }); - latch.await(10, TimeUnit.SECONDS); - if (err.get() != null) - throw err.get(); - return ref.get(); - } - /** * 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.