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<Copy>("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
This commit is contained in:
swazrgb
2020-05-24 07:36:52 +02:00
parent 34c40a6d7d
commit e23c6a0c46
3 changed files with 234 additions and 101 deletions

View File

@@ -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);

View File

@@ -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<String, String> pluginsMap = new HashMap<>();
@Getter
private final boolean developmentMode = RuneLiteProperties.getPluginDevelopmentPath().length > 0;
@Getter(AccessLevel.PUBLIC)
private final Map<String, Map<String, String>> 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<Path> 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<Path> 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<Path> getPluginPaths()
{
File[] files = pluginsRoot.toFile().listFiles(filter);
if ((files == null) || files.length == 0)
{
return Collections.emptyList();
}
List<Path> 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<String> blacklist = Arrays.asList(
".git",
"build",
"target"
);
private final List<String> 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<Path> 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<Path> 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);

View File

@@ -147,32 +147,37 @@ public class PluginListItem extends JPanel
String pluginId = pluginInfo.get("id");
hotSwapButton.setIcon(REFRESH_ICON);
externalPluginManager.uninstall(pluginId);
SwingWorker<Boolean, Void> 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);