From e23c6a0c46d28a63ebbb28993f56e0a7b44a8b52 Mon Sep 17 00:00:00 2001 From: swazrgb <65694696+swazrgb@users.noreply.github.com> Date: Sun, 24 May 2020 07:36:52 +0200 Subject: [PATCH] Support loading external plugins from sources enabling hotswapping Enables the pf4j [development mode](https://pf4j.org/doc/development-mode.html) to support loading external plugins from sources, which enables java's hotswap functionality. To use this feature set the `plugin.development.path` property or `PLUGIN_DEVELOPMENT_PATH` environment variable to the directories containing your plugins, e.g. `../plugins;../my-custom-plugins` Once set the `ExternalPluginManager` will ignore the configured repositories and externalmanager directory, and instead load all the built plugins from the specified directories. Utilizing this feature does require some additional configuration of the build of the plugins `build.gradle.kts`. Within the `subprojects` section add: ``` tasks.register("copyDeps") { into("./build/deps/") from(configurations["runtimeClasspath"]) } ``` See https://github.com/open-osrs/plugins/pull/260 for the `openosrs/plugins` change This enables the following workflow: 0. Optional tip: Set the `external.system.substitute.library.dependencies` registry value to `true` to force classpath resolution within the project 1. Open the `runelite-client` project in IntelliJ 2. Add the `plugins` repository as a module (Gradle -> Plus symbol -> `plugins/build.gradle.kts`) 3. Gradle build the client with: `build publishToMavenLocal :runelite-client:publishToMavenLocal :runelite-api:publishToMavenLocal :http-api:publishToMavenLocal` 4. Gradle build the plugins with: `build copyDeps` 5. Add the `PLUGIN_DEVELOPMENT_PATH` environment variable to the run configuration Once the above is done the edit -> reload -> edit cycle can begin: 1. Start the client in debug mode using the run goal 2. Edit the external plugin 3. Perform Build > Build Module 4. Observe hotswapping in action! 5. If hotswapping failed, or your change requires a plugin restart, click the hotswap button in the plugin list to instantly restart it --- .../runelite/client/RuneLiteProperties.java | 16 + .../client/plugins/ExternalPluginManager.java | 284 ++++++++++++------ .../client/plugins/config/PluginListItem.java | 35 ++- 3 files changed, 234 insertions(+), 101 deletions(-) diff --git a/runelite-client/src/main/java/net/runelite/client/RuneLiteProperties.java b/runelite-client/src/main/java/net/runelite/client/RuneLiteProperties.java index 3d6ff691b9..b88f2b5729 100644 --- a/runelite-client/src/main/java/net/runelite/client/RuneLiteProperties.java +++ b/runelite-client/src/main/java/net/runelite/client/RuneLiteProperties.java @@ -24,6 +24,7 @@ */ package net.runelite.client; +import com.google.common.base.Strings; import java.io.IOException; import java.io.InputStream; import java.util.Properties; @@ -43,6 +44,7 @@ public class RuneLiteProperties private static final String PATREON_LINK = "runelite.patreon.link"; private static final String LAUNCHER_VERSION_PROPERTY = "launcher.version"; private static final String PLUGIN_PATH = "plugin.path"; + private static final String PLUGIN_DEVELOPMENT_PATH = "plugin.development.path"; private static final String TROUBLESHOOTING_LINK = "runelite.wiki.troubleshooting.link"; private static final String BUILDING_LINK = "runelite.wiki.building.link"; private static final String DNS_CHANGE_LINK = "runelite.dnschange.link"; @@ -146,6 +148,20 @@ public class RuneLiteProperties return pluginPath.equals("") ? null : pluginPath; } + public static String[] getPluginDevelopmentPath() + { + // First check if property supplied as environment variable PLUGIN_DEVELOPMENT_PATHS + String developmentPluginPaths = System.getenv(PLUGIN_DEVELOPMENT_PATH.replace('.', '_').toUpperCase()); + + if (Strings.isNullOrEmpty(developmentPluginPaths)) + { + // Otherwise check the property file + developmentPluginPaths = properties.getProperty(PLUGIN_DEVELOPMENT_PATH); + } + + return Strings.isNullOrEmpty(developmentPluginPaths) ? new String[0] : developmentPluginPaths.split(";"); + } + public static String getImgurClientId() { return properties.getProperty(IMGUR_CLIENT_ID); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/ExternalPluginManager.java b/runelite-client/src/main/java/net/runelite/client/plugins/ExternalPluginManager.java index 05a5d9aa6c..ab1796ecae 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/ExternalPluginManager.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/ExternalPluginManager.java @@ -31,6 +31,7 @@ import com.google.inject.Key; import com.google.inject.Module; import java.io.Closeable; import java.io.File; +import java.io.FileFilter; import java.io.IOException; import java.lang.reflect.InvocationTargetException; import java.net.MalformedURLException; @@ -39,6 +40,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -79,8 +81,13 @@ import net.runelite.client.util.Groups; import net.runelite.client.util.MiscUtils; import net.runelite.client.util.SwingUtil; import org.jgroups.Message; +import org.pf4j.BasePluginLoader; +import org.pf4j.CompoundPluginLoader; +import org.pf4j.CompoundPluginRepository; import org.pf4j.DefaultPluginManager; import org.pf4j.DependencyResolver; +import org.pf4j.DevelopmentPluginClasspath; +import org.pf4j.DevelopmentPluginRepository; import org.pf4j.JarPluginLoader; import org.pf4j.JarPluginRepository; import org.pf4j.ManifestPluginDescriptorFinder; @@ -113,6 +120,8 @@ public class ExternalPluginManager private final EventBus eventBus; private final ConfigManager configManager; private final Map pluginsMap = new HashMap<>(); + @Getter + private final boolean developmentMode = RuneLiteProperties.getPluginDevelopmentPath().length > 0; @Getter(AccessLevel.PUBLIC) private final Map> pluginsInfoMap = new HashMap<>(); private final Groups groups; @@ -152,45 +161,182 @@ public class ExternalPluginManager @Override protected PluginDescriptorFinder createPluginDescriptorFinder() { - return new ManifestPluginDescriptorFinder(); - } - - @Override - protected PluginRepository createPluginRepository() - { - return new JarPluginRepository(getPluginsRoot()) + return new ManifestPluginDescriptorFinder() { - @Override - public List getPluginPaths() + protected Path getManifestPath(Path pluginPath) { - File[] files = pluginsRoot.toFile().listFiles(filter); - - if ((files == null) || files.length == 0) + if (isDevelopment()) { - return Collections.emptyList(); + // The superclass performs a find, which is slow in development mode since we're pointing + // at a sources directory, which can have a lot of files. The external plugin template + // will always output the manifest at the following location, so we can hardcode this path. + return pluginPath.resolve("build/tmp/jar/MANIFEST.MF"); } - List paths = new ArrayList<>(files.length); - for (File file : files) - { - paths.add(file.toPath()); - } - - return paths; + return super.getManifestPath(pluginPath); } }; } @Override - protected PluginLoader createPluginLoader() + protected PluginRepository createPluginRepository() { - return new JarPluginLoader(this); + CompoundPluginRepository compoundPluginRepository = new CompoundPluginRepository(); + + if (isNotDevelopment()) + { + JarPluginRepository jarPluginRepository = new JarPluginRepository(getPluginsRoot()) + { + @Override + public List getPluginPaths() + { + File[] files = pluginsRoot.toFile().listFiles(filter); + + if ((files == null) || files.length == 0) + { + return Collections.emptyList(); + } + + List paths = new ArrayList<>(files.length); + for (File file : files) + { + paths.add(file.toPath()); + } + + return paths; + } + }; + + compoundPluginRepository.add(jarPluginRepository); + } + + if (isDevelopment()) + { + FileFilter pluginsFilter = new FileFilter() + { + private final List blacklist = Arrays.asList( + ".git", + "build", + "target" + ); + + private final List buildFiles = Arrays.asList( + "%s.gradle.kts", + "%s.gradle" + ); + + @Override + public boolean accept(File pathName) + { + // Check if this path looks like a plugin development directory + if (!pathName.isDirectory()) + { + return false; + } + + String dirName = pathName.getName(); + if (blacklist.contains(dirName)) + { + return false; + } + + boolean isPlugin = false; + + // By convention plugins their directory is $name and they have a $name.gradle.kts or $name.gradle file in their root + for (String buildFile : buildFiles) + { + if (new File(pathName, String.format(buildFile, dirName)).exists()) + { + isPlugin = true; + break; + } + } + + // It is a plugin directory, but we should also check if it can actually be loaded + if (!new File(pathName, "build/tmp/jar/MANIFEST.MF").exists()) + { + return false; + } + + return isPlugin; + } + }; + + for (String developmentPluginPath : RuneLiteProperties.getPluginDevelopmentPath()) + { + DevelopmentPluginRepository developmentPluginRepository = new DevelopmentPluginRepository(Paths.get(developmentPluginPath)) + { + @Override + public boolean deletePluginPath(Path pluginPath) + { + // Do nothing, because we'd be deleting our sources! + return filter.accept(pluginPath.toFile()); + } + }; + + developmentPluginRepository.setFilter(pluginsFilter); + compoundPluginRepository.add(developmentPluginRepository); + } + } + + return compoundPluginRepository; } @Override - public RuntimeMode getRuntimeMode() + protected PluginLoader createPluginLoader() { - return RuneLiteProperties.getLauncherVersion() == null ? RuntimeMode.DEVELOPMENT : RuntimeMode.DEPLOYMENT; + return new CompoundPluginLoader() + .add(new BasePluginLoader(this, new DevelopmentPluginClasspath().addJarsDirectories("build/deps")), this::isDevelopment) + .add(new JarPluginLoader(this), this::isNotDevelopment); + } + + @Override + public void loadPlugins() + { + if (Files.notExists(pluginsRoot) || !Files.isDirectory(pluginsRoot)) + { + log.warn("No '{}' root", pluginsRoot); + return; + } + + List pluginPaths = pluginRepository.getPluginPaths(); + + if (pluginPaths.isEmpty()) + { + log.warn("No plugins"); + return; + } + + log.debug("Found {} possible plugins: {}", pluginPaths.size(), pluginPaths); + + for (Path pluginPath : pluginPaths) + { + try + { + loadPluginFromPath(pluginPath); + } + catch (PluginRuntimeException e) + { + if (!(e instanceof PluginAlreadyLoadedException)) + { + log.error("Could not load plugin {}", pluginPath, e); + } + } + } + + try + { + resolvePlugins(); + } + catch (PluginRuntimeException e) + { + if (e instanceof DependencyResolver.DependenciesNotFoundException) + { + throw e; + } + + log.error(e.getMessage(), e); + } } @Override @@ -257,52 +403,9 @@ public class ExternalPluginManager } @Override - public void loadPlugins() + public RuntimeMode getRuntimeMode() { - if (Files.notExists(pluginsRoot) || !Files.isDirectory(pluginsRoot)) - { - log.warn("No '{}' root", pluginsRoot); - return; - } - - List pluginPaths = pluginRepository.getPluginPaths(); - - if (pluginPaths.isEmpty()) - { - log.warn("No plugins"); - return; - } - - log.debug("Found {} possible plugins: {}", pluginPaths.size(), pluginPaths); - - for (Path pluginPath : pluginPaths) - { - try - { - loadPluginFromPath(pluginPath); - } - catch (PluginRuntimeException e) - { - if (!(e instanceof PluginAlreadyLoadedException)) - { - log.error(e.getMessage(), e); - } - } - } - - try - { - resolvePlugins(); - } - catch (PluginRuntimeException e) - { - if (e instanceof DependencyResolver.DependenciesNotFoundException) - { - throw e; - } - - log.error(e.getMessage(), e); - } + return developmentMode ? RuntimeMode.DEVELOPMENT : RuntimeMode.DEPLOYMENT; } @Override @@ -1023,30 +1126,39 @@ public class ExternalPluginManager return true; } - // Null version returns the last release version of this plugin for given system version try { - PluginInfo.PluginRelease latest = updateManager.getLastPluginRelease(pluginId); - - if (latest == null) + if (!developmentMode) { - try + PluginInfo.PluginRelease latest = updateManager.getLastPluginRelease(pluginId); + + // Null version returns the last release version of this plugin for given system version + if (latest == null) { - SwingUtil.syncExec(() -> - JOptionPane.showMessageDialog(ClientUI.getFrame(), - pluginId + " is outdated and cannot be installed", - "Installation error", - JOptionPane.ERROR_MESSAGE)); - } - catch (InvocationTargetException | InterruptedException ignored) - { - return false; + try + { + SwingUtil.syncExec(() -> + JOptionPane.showMessageDialog(ClientUI.getFrame(), + pluginId + " is outdated and cannot be installed", + "Installation error", + JOptionPane.ERROR_MESSAGE)); + } + catch (InvocationTargetException | InterruptedException ignored) + { + return false; + } + + return true; } - return true; + updateManager.installPlugin(pluginId, null); + } + else + { + // In development mode our plugin will already be present in a repository, so we can just load it + externalPluginManager.loadPlugins(); + externalPluginManager.startPlugin(pluginId); } - - updateManager.installPlugin(pluginId, null); scanAndInstantiate(loadPlugin(pluginId), true, true); diff --git a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java index b744861106..b3143c1f38 100644 --- a/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java +++ b/runelite-client/src/main/java/net/runelite/client/plugins/config/PluginListItem.java @@ -147,32 +147,37 @@ public class PluginListItem extends JPanel String pluginId = pluginInfo.get("id"); hotSwapButton.setIcon(REFRESH_ICON); - externalPluginManager.uninstall(pluginId); - SwingWorker worker = new SwingWorker<>() + new SwingWorker<>() { @Override protected Boolean doInBackground() { return externalPluginManager.uninstall(pluginId); } - }; - worker.execute(); - JOptionPane.showMessageDialog(ClientUI.getFrame(), - pluginId + " is unloaded, put the new jar file in the externalmanager folder and click `ok`", - "Hotswap " + pluginId, - JOptionPane.INFORMATION_MESSAGE); - - worker = new SwingWorker<>() - { @Override - protected Boolean doInBackground() + protected void done() { - return externalPluginManager.reloadStart(pluginId); + // In development mode our plugins will be loaded directly from sources, so we don't need to prompt + if (!externalPluginManager.isDevelopmentMode()) + { + JOptionPane.showMessageDialog(ClientUI.getFrame(), + pluginId + " is unloaded, put the new jar file in the externalmanager folder and click `ok`", + "Hotswap " + pluginId, + JOptionPane.INFORMATION_MESSAGE); + } + + new SwingWorker<>() + { + @Override + protected Boolean doInBackground() + { + return externalPluginManager.reloadStart(pluginId); + } + }.execute(); } - }; - worker.execute(); + }.execute(); }); hotSwapButton.setVisible(true);