Merge pull request #8 from layue13/feat/plugin-dependencies

feat: 插件之间的依赖、类共享与类型化服务
This commit is contained in:
layue13 authored and GitHub committed 2026-10-22 19:42:23 +08:00
commit 36dd9ebb33
19 files changed
+960 -41

No files matched your search

+1 -1
View File
@@ -37,7 +37,7 @@ testing/smoke/ smoke scripts and dated records; testing/benchmarks/ likewis
```
- `proxy-core` — the proxy runtime (`dev.moonbridge.app` = startup/config, `dev.moonbridge.core.*` = everything else). The only module that uses Netty.
- `proxy-plugin-api` — the stable plugin boundary. Plugins are discovered with `META-INF/services/dev.moonbridge.api.Plugin` and loaded in separate class loaders by `PluginHost`.
- `proxy-plugin-api` — the stable plugin boundary. Plugins are discovered with `META-INF/services/dev.moonbridge.api.Plugin` or an optional `META-INF/moonbridge-plugin.yml` (id, version, main, dependencies) and loaded in separate class loaders by `PluginHost`. Descriptor plugins load in dependency order (`PluginGraph`), see their declared dependencies' classes (`PluginClassLoader`) and share typed services (`PluginServiceRegistry`).
- `messaging` — message envelope, channel API and wire codec shared by the proxy and backends.
- `backend` — the Bukkit/Uranium host plugin `MoonBridgeBackend` (its jar bundles `messaging`), its service API (`dev.moonbridge.bukkit`) and the control channel client (`dev.moonbridge.backendchannel`). Business plugins compile against this jar `compileOnly`.
- `messaging` and `backend` use `moonbridge.java8-library-conventions` (`--release 8`, for Java 8 backends such as Uranium) and must not pull in newer APIs.
+1 -1
View File
@@ -73,7 +73,7 @@ plugins:
proxy-id: "proxy-1"
```
插件数据位于 `plugins/data/dev.moonbridge.luckperms.LuckPermsMoonBridgePlugin/`;LuckPerms 的存储与规则配置使用此目录中的配置文件。不要同时启用另一个权限提供者。
该插件的描述文件 id 为 `luckperms`,也可以写成 `enabled: { luckperms: { proxy-id: "proxy-1" } }`;其他插件可以在 `dependencies` 中声明它,见[依赖其他插件](plugins.md#依赖其他插件)。插件数据位于 `plugins/data/dev.moonbridge.luckperms.LuckPermsMoonBridgePlugin/`;LuckPerms 的存储与规则配置使用此目录中的配置文件。不要同时启用另一个权限提供者。
新配置默认使用共享 MariaDB 13.0.2,启用插件前需创建权限数据库并填写连接信息。单机开发与内部验收可显式选择 H2。首次启动会由 LuckPerms 自身的依赖管理器下载带固定校验值的运行库,缓存到数据目录的 `libs/`;离线部署应先在可联网环境完成启动并带上该缓存。插件 JAR 也作为 `uk.potatolab.moonbridge:luckperms-moonbridge` 发布,版本与同次构建的 MoonBridge API 一致。
+42
View File
@@ -10,6 +10,48 @@
关闭或插件失败时,宿主移除该插件的事件订阅、后端与命令注册。关闭时,宿主先终止未决访问请求并停止接收新回调,再逆序调用 `onDisable()`。所有关闭钩子共享 10 秒期限;插件忽略中断时仍可能继续运行,因此插件必须自行停止数据库/调度器等资源。随后关闭类加载器。当前没有插件热卸载或热加载入口。
## 依赖其他插件
插件之间默认互相隔离:每个 JAR 有自己的类加载器,只能看到插件 API 和自己的类。要让一个插件使用另一个插件的类和服务,两边都在 JAR 中放一个 `META-INF/moonbridge-plugin.yml`:
```yaml
id: islands # 小写字母、数字和连字符,最多 64 个字符,全局唯一
version: 1.2.0
main: com.example.islands.IslandsPlugin
dependencies:
- id: economy # 必需:未安装或未在 plugins.enabled 中启用时启动失败
- id: luckperms
optional: true # 可选:存在时先加载,不存在时忽略
```
- 有描述文件时以 `main` 为入口,不再读取该 JAR 的 `META-INF/services`;没有描述文件的插件照旧加载,但既不能依赖别人,也不能被依赖。
- `plugins.enabled` 可以用 id(`islands:`)或实现类全名作键,同一个插件不能两种写法同时出现。数据目录仍按实现类全名,已有数据不需要迁移。
- 加载顺序:依赖先于依赖它的插件执行 `onLoad` 和 `onEnable`,`onDisable` 反过来;互不依赖的插件保持 JAR 文件名顺序。
- 启动时检查并给出准确原因后失败:`Plugin islands requires economy, which is not installed`、`… which is installed but not enabled in plugins.enabled`、`Plugin dependency cycle: a -> b -> a`、两个 JAR 使用同一 id、描述文件字段不合法(带 JAR 名和字段)。
- 类可见性:先查插件 API、JDK 和日志等共享类,再查插件自己的 JAR 和 `addLibrary` 加入的库,最后查声明的依赖(以及依赖的依赖)。只有声明过的依赖可见,自带的库不会被其他插件的同名类替换。资源文件不共享。
依赖方通过类型化服务取得对方的实现,而不是直接持有对方的 `Plugin` 实例。提供方发布接口,在 `onLoad` 中注册实现:
```java
// economy 插件
public interface EconomyApi { long balance(UUID player); }
@Override public void onLoad(PluginContext context) {
context.services().register(EconomyApi.class, new EconomyService());
}
// islands 插件:dependencies 中声明 economy
EconomyApi economy = context.services().require(EconomyApi.class);
context.plugins().find("luckperms").ifPresent(info -> context.logger().info("LuckPerms {}", info.version()));
```
- 每个类型只能有一个提供方;第二个插件注册同一类型时启动失败。
- `find` / `require` 只能看到自己和依赖链上的插件注册的服务;没有声明依赖的插件即使能加载到同一类型(例如 JDK 接口)也查不到。
- 服务随提供方停用自动注销,也可以用 `register` 返回的 `ServiceRegistration` 提前注销。依赖方先于依赖停用,所以不会在运行中看到依赖的服务消失。
- `plugins()` 列出所有带描述文件的已加载插件(id、版本、声明的依赖),`self()` 是自己的描述。
依赖方在构建时只需要对方的接口,例如 Gradle 中 `compileOnly("com.example:economy-api:1.0.0")`;不要把对方的类打进自己的 JAR,否则自带的副本会优先于依赖提供的类,两边的类型就不再是同一个。不需要共享类的松散协作继续用下文的 Channel 消息。原生 LuckPerms 平台插件的 id 是 `luckperms`。
## 玩家与后端
- `Players.online()` 和 `find(PlayerIdentity)` 返回不可变 `PlayerView`。`PlayerIdentity` 同时包含玩家 UUID 和本次连接代次;异步转服要保留这个完整身份,不能用旧连接去操作重连后的玩家。
@@ -1,13 +1,17 @@
package dev.moonbridge.core.plugin;
import dev.moonbridge.messaging.internal.LocalMessaging;
import dev.moonbridge.api.PluginInfo;
import dev.moonbridge.api.Players;
import dev.moonbridge.core.backend.BackendCatalog;
import dev.moonbridge.core.event.PermissionService;
import java.util.List;
import java.util.UUID;
import java.util.function.Supplier;
/** The proxy services a plugin context is built from. */
record HostServices(BackendCatalog catalog, Players players, UUID proxyEpoch, PermissionService permissions,
LocalMessaging messaging, CommandService commands, EventRouter events,
HostLifecycle lifecycle) { }
HostLifecycle lifecycle, PluginServiceRegistry pluginServices,
Supplier<List<PluginInfo>> pluginInfos) { }
@@ -7,17 +7,64 @@ import java.net.URLClassLoader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Set;
import java.util.jar.JarFile;
/** URLClassLoader with a supported, narrowly-scoped hook for plugin-owned extension libraries. */
/**
* URLClassLoader with a supported, narrowly-scoped hook for plugin-owned extension libraries, which also sees the
* classes of the plugins it declared as dependencies. A class is looked up in the proxy API (the parent), then in
* this plugin's own JAR and libraries, then in each dependency the same way. Own classes come before dependency
* classes, so a library a plugin bundles is never replaced by another plugin's copy. Resources are not shared.
* Dependencies form an acyclic graph, so lookups never wait on each other's locks in a loop.
*/
final class PluginClassLoader extends URLClassLoader {
static { registerAsParallelCapable(); }
private final Set<Path> libraries = new HashSet<>();
private final List<PluginClassLoader> dependencies;
PluginClassLoader(java.net.URL[] urls, ClassLoader parent) {
this(urls, parent, List.of());
}
PluginClassLoader(java.net.URL[] urls, ClassLoader parent, List<PluginClassLoader> dependencies) {
super(urls, parent);
this.dependencies = List.copyOf(dependencies);
}
@Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
Class<?> type = findLoadedClass(name);
if (type == null) {
try {
type = getParent().loadClass(name);
} catch (ClassNotFoundException notShared) {
type = loadOwnOrFromDependencies(name);
if (type == null) throw new ClassNotFoundException(name);
}
}
if (resolve) resolveClass(type);
return type;
}
/** This plugin's own class, or a dependency's; null when none of them defines it. */
private Class<?> loadOwnOrFromDependencies(String name) {
synchronized (getClassLoadingLock(name)) {
Class<?> type = findLoadedClass(name);
if (type != null) return type;
try {
return findClass(name);
} catch (ClassNotFoundException notOwn) {
// Fall through to the declared dependencies.
}
}
for (PluginClassLoader dependency : dependencies) {
Class<?> type = dependency.loadOwnOrFromDependencies(name);
if (type != null) return type;
}
return null;
}
synchronized void addLibrary(Path requested, Path pluginDataDirectory) throws IOException {
@@ -19,6 +19,10 @@ import dev.moonbridge.api.PlayerView;
import dev.moonbridge.api.Players;
import dev.moonbridge.api.Plugin;
import dev.moonbridge.api.PluginContext;
import dev.moonbridge.api.PluginInfo;
import dev.moonbridge.api.PluginServices;
import dev.moonbridge.api.Plugins;
import dev.moonbridge.api.ServiceRegistration;
import dev.moonbridge.api.ServerDefinition;
import dev.moonbridge.api.ServerRegistration;
import dev.moonbridge.api.ServerView;
@@ -58,13 +62,48 @@ import java.util.UUID;
private final Map<String, String> settings;
private final Path dataDirectory;
private final PluginClassLoader pluginClassLoader;
/** Null for a plugin loaded without a descriptor. */
private final PluginInfo info;
/** Ids of every plugin this one depends on, directly or through other dependencies. */
private final Set<String> dependencyIds;
private final PluginServices pluginServices;
private final Plugins pluginDirectory;
final Set<EventRegistration<?, ?>> eventSubscriptions = new HashSet<>();
volatile boolean active = true;
private boolean eventRegistrationOpen;
PluginContextImpl(HostServices host, BackendOwner owner, Map<String, String> settings, Path dataDirectory,
PluginClassLoader pluginClassLoader) {
this(host, owner, settings, dataDirectory, pluginClassLoader, null, Set.of());
}
PluginContextImpl(HostServices host, BackendOwner owner, Map<String, String> settings, Path dataDirectory,
PluginClassLoader pluginClassLoader, PluginInfo info, Set<String> dependencyIds) {
this.host = host;
this.info = info;
this.dependencyIds = Set.copyOf(dependencyIds);
this.pluginServices = new PluginServices() {
@Override public <T> ServiceRegistration register(Class<T> type, T service) {
synchronized (PluginContextImpl.this) {
requireActive();
return host.pluginServices().register(PluginContextImpl.this, type, service);
}
}
@Override public <T> Optional<T> find(Class<T> type) {
synchronized (PluginContextImpl.this) {
requireActive();
return host.pluginServices().find(PluginContextImpl.this, type);
}
}
};
this.pluginDirectory = new Plugins() {
@Override public Optional<PluginInfo> self() { return Optional.ofNullable(info); }
@Override public Optional<PluginInfo> find(String id) {
Objects.requireNonNull(id, "id");
return all().stream().filter(plugin -> plugin.id().equals(id)).findFirst();
}
@Override public List<PluginInfo> all() { return host.pluginInfos().get(); }
};
this.owner = owner;
this.pluginMessaging = host.messaging().openScope(owner.id());
this.pluginPlayers = new PluginPlayers(this);
@@ -99,6 +138,16 @@ import java.util.UUID;
@Override public Commands commands() { return pluginCommands; }
@Override public Events events() { return pluginEvents; }
@Override public Messaging messaging() { return pluginMessaging; }
@Override public PluginServices services() { return pluginServices; }
@Override public Plugins plugins() { return pluginDirectory; }
/** True when {@code provider} is one of this plugin's dependencies, directly or transitively. */
boolean dependsOn(PluginContextImpl provider) {
return provider.info != null && dependencyIds.contains(provider.info.id());
}
/** The descriptor id when there is one, else the internal owner id. */
String displayName() { return info != null ? info.id() : owner.id(); }
@Override public Logger logger() { return logger; }
@Override public dev.moonbridge.api.permission.Permissions permissions() { return pluginPermissions; }
@Override public Path dataDirectory() {
@@ -119,6 +168,7 @@ import java.util.UUID;
active = false;
eventRegistrationOpen = false;
pluginMessaging.close();
host.pluginServices().removeOwner(this);
revokeEventSubscriptions();
host.commands().removeOwner(this);
host.catalog().removeOwner(owner);
@@ -0,0 +1,94 @@
package dev.moonbridge.core.plugin;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
import dev.moonbridge.api.PluginInfo;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.jar.JarFile;
import java.util.regex.Pattern;
/**
* The optional {@code META-INF/moonbridge-plugin.yml} of a plugin JAR: an id other plugins can depend on, a version,
* the entry class and the plugins this one needs. A JAR without it is loaded as before, through
* {@code META-INF/services}, and takes no part in dependencies.
*/
record PluginDescriptor(String id, String version, String main, List<Dependency> dependencies) {
static final String RESOURCE = "META-INF/moonbridge-plugin.yml";
private static final Pattern ID = Pattern.compile("[a-z0-9][a-z0-9-]{0,63}");
private static final Pattern CLASS_NAME =
Pattern.compile("[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*(\\.[\\p{javaJavaIdentifierStart}][\\p{javaJavaIdentifierPart}]*)*");
private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory())
.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES);
record Dependency(String id, boolean optional) {
@JsonCreator Dependency(@JsonProperty("id") String id, @JsonProperty("optional") Boolean optional) {
this(requireId(id, "dependency id"), optional != null && optional);
}
}
PluginDescriptor {
requireId(id, "id");
requireVersion(version);
requireMain(main);
dependencies = dependencies == null ? List.of() : new java.util.ArrayList<>(dependencies);
var seen = new HashSet<String>();
for (Dependency dependency : dependencies) {
if (dependency == null) throw new IllegalArgumentException("plugin " + id + " has an empty dependency entry");
if (dependency.id().equals(id)) throw new IllegalArgumentException("plugin " + id + " depends on itself");
if (!seen.add(dependency.id())) {
throw new IllegalArgumentException("plugin " + id + " lists dependency " + dependency.id() + " twice");
}
}
dependencies = List.copyOf(dependencies);
}
/** Reads the JAR's descriptor; empty when the JAR has none. */
static Optional<PluginDescriptor> read(Path jar) throws IOException {
try (JarFile archive = new JarFile(jar.toFile())) {
var entry = archive.getJarEntry(RESOURCE);
if (entry == null) return Optional.empty();
try (var input = archive.getInputStream(entry)) {
PluginDescriptor descriptor = YAML.readValue(input, PluginDescriptor.class);
if (descriptor == null) throw new IOException(RESOURCE + " is empty");
return Optional.of(descriptor);
} catch (IOException | IllegalArgumentException invalid) {
throw new IOException("Invalid " + RESOURCE + " in " + jar.getFileName() + ": " + invalid.getMessage(),
invalid);
}
}
}
PluginInfo info() {
return new PluginInfo(id, version, dependencies.stream().map(Dependency::id).toList());
}
private static String requireId(String id, String field) {
if (id == null || !ID.matcher(id).matches()) {
throw new IllegalArgumentException(field + " must match " + ID.pattern() + ": " + id);
}
return id;
}
private static String requireVersion(String version) {
if (version == null || version.isBlank() || version.length() > 64 || !version.equals(version.trim())) {
throw new IllegalArgumentException("version must be 1-64 characters without surrounding spaces");
}
return version;
}
private static String requireMain(String main) {
if (main == null || !CLASS_NAME.matcher(main).matches()) {
throw new IllegalArgumentException("main must be a fully qualified class name: " + main);
}
return main;
}
}
@@ -0,0 +1,66 @@
package dev.moonbridge.core.plugin;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
/** Orders plugins so that every plugin comes after the plugins it depends on. */
final class PluginGraph {
private PluginGraph() { }
/**
* Returns {@code items} with dependencies first. Among plugins whose dependencies are already placed, input order
* wins, so plugins without dependencies keep their JAR filename order. {@code dependsOn} lists ids that are
* present in {@code items}; a plugin without an id has a null id and no dependencies.
*/
static <T> List<T> order(List<T> items, Function<T, String> id, Function<T, List<String>> dependsOn) {
var remaining = new ArrayList<>(items);
var placed = new HashSet<String>();
var ordered = new ArrayList<T>(items.size());
while (!remaining.isEmpty()) {
T next = null;
for (T item : remaining) {
if (placed.containsAll(dependsOn.apply(item))) {
next = item;
break;
}
}
if (next == null) throw new IllegalStateException("Plugin dependency cycle: " + cycle(remaining, id, dependsOn));
remaining.remove(next);
ordered.add(next);
if (id.apply(next) != null) placed.add(id.apply(next));
}
return ordered;
}
/** Every plugin reachable through {@code dependsOn}, nearest first, not including {@code start}. */
static Set<String> reachable(String start, Function<String, List<String>> dependsOn) {
var seen = new LinkedHashSet<String>();
var pending = new ArrayList<>(dependsOn.apply(start));
while (!pending.isEmpty()) {
String next = pending.removeFirst();
if (seen.add(next)) pending.addAll(dependsOn.apply(next));
}
return seen;
}
/** Follows unplaced dependencies from any remaining plugin until one repeats: that loop is the cycle. */
private static <T> String cycle(List<T> remaining, Function<T, String> id, Function<T, List<String>> dependsOn) {
Map<String, T> byId = new LinkedHashMap<>();
for (T item : remaining) if (id.apply(item) != null) byId.put(id.apply(item), item);
var path = new ArrayList<String>();
String current = byId.keySet().iterator().next();
while (!path.contains(current)) {
path.add(current);
current = dependsOn.apply(byId.get(current)).stream().filter(byId::containsKey).findFirst().orElseThrow();
}
var loop = new ArrayList<>(path.subList(path.indexOf(current), path.size()));
loop.add(current);
return String.join(" -> ", loop);
}
}
@@ -8,6 +8,7 @@ import dev.moonbridge.api.PlacementDecision;
import dev.moonbridge.api.PlayerView;
import dev.moonbridge.api.Players;
import dev.moonbridge.api.Plugin;
import dev.moonbridge.api.PluginInfo;
import dev.moonbridge.api.permission.PermissionProvider;
import net.kyori.adventure.text.Component;
import dev.moonbridge.core.backend.BackendCatalog;
@@ -30,6 +31,7 @@ import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ExecutionException;
@@ -65,6 +67,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
private final HostServices services;
private final List<URLClassLoader> classLoaders = new ArrayList<>();
private final List<LoadedPlugin> plugins = new ArrayList<>();
private final PluginServiceRegistry pluginServices = new PluginServiceRegistry();
/** Descriptor plugins in load order, readable by plugins on any thread. */
private volatile List<PluginInfo> pluginInfos = List.of();
private volatile BackendChannelTransport backendChannelTransport;
public PluginHost(BackendCatalog catalog, Players players, Duration placementTimeout) {
@@ -143,7 +148,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
this.commandService = new CommandService(lifecycle, this, players, permissionService, commandWorkers, timer);
this.placement = new PlacementService(lifecycle, this, catalog, placementTimeout, callbacks, timer);
this.services = new HostServices(catalog, players, proxyEpoch, permissionService, localMessaging,
commandService, events, lifecycle);
commandService, events, lifecycle, pluginServices, () -> pluginInfos);
}
private static ThreadPoolExecutor boundedPool(int threads, int queueCapacity, String name) {
@@ -152,12 +157,16 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
new ThreadPoolExecutor.AbortPolicy());
}
/** Loads every JAR in the directory in filename order through Java's service provider mechanism. */
/** Loads every plugin in the directory: descriptor plugins in dependency order, the rest in filename order. */
public synchronized void loadPlugins(Path directory) throws IOException {
loadPlugins(directory, null);
}
/** Loads only configured provider classes; an empty map enables no external plugins. */
/**
* Loads only the configured plugins; an empty map enables no external plugins. A key is a descriptor id or a
* plugin class name. Plugins with a {@code META-INF/moonbridge-plugin.yml} are loaded after the plugins they
* depend on and can see their classes; startup fails if a required dependency is missing or not enabled.
*/
public synchronized void loadPlugins(Path directory, Map<String, Map<String, String>> enabled) throws IOException {
lifecycle.require(HostLifecycle.State.LOADING);
Objects.requireNonNull(directory, "directory");
@@ -169,41 +178,123 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
if (!Files.isDirectory(directory)) {
throw new IOException("Plugin path is not a directory: " + directory);
}
List<Path> jars = PluginJars.list(directory);
var remaining = enabled == null ? null : new HashSet<>(enabled.keySet());
for (Path jar : jars) {
try {
List<String> selected = enabled == null ? null : PluginJars.declaredProviders(jar).stream()
.filter(enabled::containsKey).toList();
if (selected != null && selected.isEmpty()) continue;
PluginClassLoader loader = new PluginClassLoader(new java.net.URL[]{jar.toUri().toURL()},
Plugin.class.getClassLoader());
classLoaders.add(loader);
if (selected == null) {
for (String className : PluginJars.declaredProviders(jar)) {
loadOne(PluginConstructors.newPlugin(loader.loadClass(className)),
jar.getFileName() + ":" + className, Map.of(),
directory.resolve("data").resolve(className), loader);
}
} else {
for (String className : selected) {
if (!remaining.remove(className)) {
throw new IllegalStateException("Duplicate configured plugin provider: " + className);
}
Plugin plugin = PluginConstructors.newPlugin(loader.loadClass(className));
loadOne(plugin, jar.getFileName() + ":" + className, enabled.get(className),
directory.resolve("data").resolve(className), loader);
}
try {
List<Candidate> ordered = resolve(installedCandidates(directory), enabled);
Map<Path, PluginClassLoader> loaders = new java.util.HashMap<>();
Map<String, Candidate> byId = new java.util.HashMap<>();
ordered.stream().filter(candidate -> candidate.id() != null)
.forEach(candidate -> byId.put(candidate.id(), candidate));
for (Candidate candidate : ordered) {
PluginClassLoader loader = loaders.get(candidate.jar());
if (loader == null) {
// Dependencies come earlier in the order, so their loaders already exist.
List<PluginClassLoader> dependencyLoaders = candidate.presentDependencies().stream()
.map(id -> loaders.get(byId.get(id).jar())).toList();
loader = new PluginClassLoader(new java.net.URL[]{candidate.jar().toUri().toURL()},
Plugin.class.getClassLoader(), dependencyLoaders);
loaders.put(candidate.jar(), loader);
classLoaders.add(loader);
}
Plugin plugin;
try {
plugin = PluginConstructors.newPlugin(loader.loadClass(candidate.className()));
} catch (Throwable failure) {
throw new IOException("Failed to load plugin JAR " + candidate.jar(), failure);
}
Set<String> dependencyIds = candidate.id() == null ? Set.of()
: PluginGraph.reachable(candidate.id(), id -> byId.get(id).presentDependencies());
try {
loadOne(plugin, candidate.jar().getFileName() + ":" + candidate.className(), candidate.settings(),
directory.resolve("data").resolve(candidate.className()), loader,
candidate.descriptor() == null ? null : candidate.descriptor().info(), dependencyIds);
} catch (RuntimeException failure) {
throw new IOException("Failed to load plugin JAR " + candidate.jar(), failure);
}
}
} catch (IOException | RuntimeException | Error failure) {
close();
throw failure;
}
}
/** One plugin entry point found in a JAR: the descriptor's main class, or a META-INF/services provider. */
private record Candidate(Path jar, String className, PluginDescriptor descriptor, Map<String, String> settings,
List<String> presentDependencies) {
String id() { return descriptor == null ? null : descriptor.id(); }
}
private static List<Candidate> installedCandidates(Path directory) throws IOException {
List<Candidate> installed = new ArrayList<>();
Map<String, Path> ids = new java.util.HashMap<>();
for (Path jar : PluginJars.list(directory)) {
Optional<PluginDescriptor> descriptor = PluginDescriptor.read(jar);
if (descriptor.isPresent()) {
Path other = ids.putIfAbsent(descriptor.get().id(), jar);
if (other != null) {
throw new IOException("Plugin id " + descriptor.get().id() + " is used by both "
+ other.getFileName() + " and " + jar.getFileName());
}
installed.add(new Candidate(jar, descriptor.get().main(), descriptor.get(), Map.of(), List.of()));
} else {
for (String className : PluginJars.declaredProviders(jar)) {
installed.add(new Candidate(jar, className, null, Map.of(), List.of()));
}
} catch (Throwable failure) {
close();
throw new IOException("Failed to load plugin JAR " + jar, failure);
}
}
return installed;
}
/** Picks the enabled plugins, checks their dependencies and returns them in load order. */
private static List<Candidate> resolve(List<Candidate> installed, Map<String, Map<String, String>> enabled)
throws IOException {
var remaining = enabled == null ? null : new HashSet<>(enabled.keySet());
List<Candidate> selected = new ArrayList<>();
for (Candidate candidate : installed) {
Map<String, String> settings = Map.of();
if (enabled != null) {
boolean byId = candidate.id() != null && enabled.containsKey(candidate.id());
boolean byClass = enabled.containsKey(candidate.className());
if (byId && byClass) {
throw new IOException("Plugin " + candidate.id() + " is enabled twice, by id and by class name "
+ candidate.className());
}
if (!byId && !byClass) continue;
String key = byId ? candidate.id() : candidate.className();
if (!remaining.remove(key)) throw new IOException("Duplicate configured plugin provider: " + key);
settings = enabled.get(key);
}
selected.add(new Candidate(candidate.jar(), candidate.className(), candidate.descriptor(), settings,
List.of()));
}
if (remaining != null && !remaining.isEmpty()) {
close();
throw new IOException("Configured plugins were not found: " + remaining);
}
Set<String> installedIds = new HashSet<>();
installed.forEach(candidate -> { if (candidate.id() != null) installedIds.add(candidate.id()); });
Set<String> selectedIds = new HashSet<>();
selected.forEach(candidate -> { if (candidate.id() != null) selectedIds.add(candidate.id()); });
List<Candidate> withDependencies = new ArrayList<>();
for (Candidate candidate : selected) {
List<String> present = new ArrayList<>();
if (candidate.descriptor() != null) {
for (PluginDescriptor.Dependency dependency : candidate.descriptor().dependencies()) {
if (selectedIds.contains(dependency.id())) {
present.add(dependency.id());
} else if (!dependency.optional()) {
throw new IOException("Plugin " + candidate.id() + " requires " + dependency.id() + ", which is "
+ (installedIds.contains(dependency.id()) ? "installed but not enabled in plugins.enabled"
: "not installed"));
}
}
}
withDependencies.add(new Candidate(candidate.jar(), candidate.className(), candidate.descriptor(),
candidate.settings(), List.copyOf(present)));
}
try {
return PluginGraph.order(withDependencies, Candidate::id, Candidate::presentDependencies);
} catch (IllegalStateException cycle) {
throw new IOException(cycle.getMessage());
}
}
/** Loads plugin instances directly, primarily for embedding and tests. */
@@ -213,7 +304,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
for (Plugin plugin : instances) {
Objects.requireNonNull(plugin, "plugin");
loadOne(plugin, "instance:" + plugin.getClass().getName() + ":" + plugins.size(), Map.of(),
Path.of("plugins").resolve(plugin.getClass().getName()), null);
Path.of("plugins").resolve(plugin.getClass().getName()), null, null, Set.of());
}
} catch (RuntimeException | Error failure) {
close();
@@ -238,7 +329,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
} finally {
loaded.context.endEventRegistration();
}
LOGGER.info("Plugin {} enabled", loaded.owner.id());
LOGGER.info("Plugin {} enabled", loaded.context.displayName());
}
selectPermissionProvider();
events.freeze();
@@ -376,7 +467,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
Thread.ofVirtual().name("moonbridge-plugin-disable-" + loaded.owner.id()).start(stop);
try {
stop.get(remaining, TimeUnit.NANOSECONDS);
LOGGER.info("Plugin {} disabled", loaded.owner.id());
LOGGER.info("Plugin {} disabled", loaded.context.displayName());
} catch (TimeoutException timeout) {
stop.cancel(true);
LOGGER.warn("Plugin {} onDisable exceeded shutdown deadline", loaded.owner.id());
@@ -389,7 +480,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
}
private void loadOne(Plugin plugin, String id, Map<String, String> settings, Path dataDirectory,
PluginClassLoader pluginClassLoader) {
PluginClassLoader pluginClassLoader, PluginInfo info, Set<String> dependencyIds) {
long generation = NEXT_HOST_GENERATION.updateAndGet(value -> {
if (value == Long.MAX_VALUE) {
throw new IllegalStateException("Plugin host generation exhausted");
@@ -397,15 +488,22 @@ public final class PluginHost implements AutoCloseable, EventDispatcher {
return value + 1;
});
BackendOwner owner = new BackendOwner(id, generation);
PluginContextImpl context = new PluginContextImpl(services, owner, settings, dataDirectory, pluginClassLoader);
PluginContextImpl context = new PluginContextImpl(services, owner, settings, dataDirectory, pluginClassLoader,
info, dependencyIds);
LoadedPlugin loaded = new LoadedPlugin(plugin, owner, context);
plugins.add(loaded);
if (info != null) {
var infos = new ArrayList<>(pluginInfos);
infos.add(info);
pluginInfos = List.copyOf(infos);
}
context.beginEventRegistration();
try {
plugin.onLoad(context);
} catch (Throwable failure) {
context.deactivateAndRemove();
plugins.remove(loaded);
if (info != null) pluginInfos = pluginInfos.stream().filter(other -> other != info).toList();
throw new PluginLoadException(id, failure);
} finally {
context.endEventRegistration();
@@ -0,0 +1,40 @@
package dev.moonbridge.core.plugin;
import dev.moonbridge.api.ServiceRegistration;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
/** The host's table of plugin services: one provider per type, visible to the provider and its dependents. */
final class PluginServiceRegistry {
private record Entry(PluginContextImpl owner, Object service) { }
private final Map<Class<?>, Entry> entries = new ConcurrentHashMap<>();
<T> ServiceRegistration register(PluginContextImpl owner, Class<T> type, T service) {
Objects.requireNonNull(type, "type");
Objects.requireNonNull(service, "service");
if (!type.isInstance(service)) {
throw new IllegalArgumentException(service.getClass().getName() + " does not implement " + type.getName());
}
Entry entry = new Entry(owner, service);
Entry existing = entries.putIfAbsent(type, entry);
if (existing != null) {
throw new IllegalStateException(type.getName() + " is already provided by plugin " + existing.owner.displayName());
}
return () -> entries.remove(type, entry);
}
<T> Optional<T> find(PluginContextImpl caller, Class<T> type) {
Objects.requireNonNull(type, "type");
Entry entry = entries.get(type);
if (entry == null || (entry.owner != caller && !caller.dependsOn(entry.owner))) return Optional.empty();
return Optional.of(type.cast(entry.service));
}
void removeOwner(PluginContextImpl owner) {
entries.values().removeIf(entry -> entry.owner == owner);
}
}
@@ -0,0 +1,293 @@
package dev.moonbridge.core.plugin;
import dev.moonbridge.api.DisconnectResult;
import dev.moonbridge.api.MessageResult;
import dev.moonbridge.api.PlayerIdentity;
import dev.moonbridge.api.PlayerView;
import dev.moonbridge.api.Players;
import dev.moonbridge.api.PluginContext;
import dev.moonbridge.api.PluginInfo;
import dev.moonbridge.api.ServiceRegistration;
import dev.moonbridge.api.TransferResult;
import dev.moonbridge.core.backend.InMemoryBackendCatalog;
import net.kyori.adventure.text.Component;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import java.io.IOException;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
class PluginDependencyTest {
private static final String SERVICES = "META-INF/services/dev.moonbridge.api.Plugin";
@TempDir
Path temporary;
/** Plugins append to this list, found through the "log" setting, so the test can see lifecycle order. */
private final String logKey = "moonbridge.test.plugin-log." + UUID.randomUUID();
private final List<String> log = Collections.synchronizedList(new ArrayList<>());
private final List<String> propertyKeys = new ArrayList<>(List.of(logKey));
PluginDependencyTest() {
System.getProperties().put(logKey, log);
}
@AfterEach
void clearProperties() {
propertyKeys.forEach(System.getProperties()::remove);
}
@Test
void dependentLoadsAfterItsDependencyAndSeesItsClassesAndServices() throws Exception {
Path plugins = temporary.resolve("plugins");
// Filename order would load islands first; the dependency puts economy before it.
Path economyClasses = jar(plugins.resolve("z-economy.jar"), Map.of(
"economy/EconomyApi.java", "package economy; public interface EconomyApi { int balance(); }",
"economy/Main.java", plugin("economy", "context.services().register(EconomyApi.class, () -> 42);", "")),
descriptor("economy", "economy.Main"), List.of());
jar(plugins.resolve("a-islands.jar"), Map.of("islands/Main.java", plugin("islands", "",
"log().add(\"islands sees \" + context.services().require(economy.EconomyApi.class).balance());")),
descriptor("islands", "islands.Main", "economy"), List.of(economyClasses));
jar(plugins.resolve("m-other.jar"), Map.of("other/Main.java", plugin("other", "", """
try {
Class.forName("economy.EconomyApi", false, getClass().getClassLoader());
log().add("other sees economy classes");
} catch (ClassNotFoundException expected) {
log().add("other cannot see economy classes");
}
log().add("other finds economy info " + context.plugins().find("economy").isPresent());
""")), descriptor("other", "other.Main"), List.of());
jar(plugins.resolve("b-legacy.jar"), Map.of("legacy/Main.java", plugin("legacy", "", "")),
Map.of(SERVICES, "legacy.Main\n"), List.of());
try (var host = host()) {
host.loadPlugins(plugins, enabled("economy", "islands", "other", "legacy.Main"));
host.enable();
}
assertEquals(List.of(
"legacy load", "other load", "economy load", "islands load",
"legacy enable",
"other enable", "other cannot see economy classes", "other finds economy info true",
"economy enable",
"islands enable", "islands sees 42",
"islands disable", "economy disable", "other disable", "legacy disable"), log);
}
@Test
void pluginsSeeTheDescriptorsOfLoadedPlugins() throws Exception {
Path plugins = temporary.resolve("plugins");
String infoKey = property("info");
jar(plugins.resolve("economy.jar"), Map.of("economy/Main.java", plugin("economy", "", "")),
descriptor("economy", "economy.Main"), List.of());
jar(plugins.resolve("islands.jar"), Map.of("islands/Main.java", plugin("islands", "",
"System.getProperties().put(\"" + infoKey + "\", List.of(context.plugins().self(), context.plugins().all()));")),
descriptor("islands", "islands.Main", "economy"), List.of());
try (var host = host()) {
host.loadPlugins(plugins, enabled("economy", "islands"));
host.enable();
var economy = new PluginInfo("economy", "1.0.0", List.of());
var islands = new PluginInfo("islands", "1.0.0", List.of("economy"));
assertEquals(List.of(Optional.of(islands), List.of(economy, islands)), System.getProperties().get(infoKey));
}
}
@Test
void servicesAreOnlyVisibleToDependentsAndGoAwayWhenUnregistered() throws Exception {
Path plugins = temporary.resolve("plugins");
String registrationKey = property("registration");
String dependentKey = property("dependent");
String strangerKey = property("stranger");
jar(plugins.resolve("provider.jar"), Map.of("provider/Main.java", plugin("provider",
"System.getProperties().put(\"" + registrationKey
+ "\", context.services().register(Runnable.class, () -> { }));", "")),
descriptor("provider", "provider.Main"), List.of());
jar(plugins.resolve("dependent.jar"), Map.of("dependent/Main.java", plugin("dependent",
"System.getProperties().put(\"" + dependentKey + "\", context);", "")),
descriptor("dependent", "dependent.Main", "provider"), List.of());
jar(plugins.resolve("stranger.jar"), Map.of("stranger/Main.java", plugin("stranger",
"System.getProperties().put(\"" + strangerKey + "\", context);", "")),
descriptor("stranger", "stranger.Main"), List.of());
try (var host = host()) {
host.loadPlugins(plugins, enabled("provider", "dependent", "stranger"));
host.enable();
var dependent = (PluginContext) System.getProperties().get(dependentKey);
var stranger = (PluginContext) System.getProperties().get(strangerKey);
assertTrue(dependent.services().find(Runnable.class).isPresent());
assertFalse(stranger.services().find(Runnable.class).isPresent(),
"a plugin that did not declare the provider must not reach its services");
((ServiceRegistration) System.getProperties().get(registrationKey)).close();
assertFalse(dependent.services().find(Runnable.class).isPresent());
}
}
@Test
void secondProviderOfATypeFailsStartup() throws Exception {
Path plugins = temporary.resolve("plugins");
for (String id : List.of("first", "second")) {
jar(plugins.resolve(id + ".jar"), Map.of(id + "/Main.java", plugin(id,
"context.services().register(Runnable.class, () -> { });", "")),
descriptor(id, id + ".Main"), List.of());
}
var failure = loadFailure(plugins, enabled("first", "second"));
assertTrue(causes(failure).contains("java.lang.Runnable is already provided by plugin first"), causes(failure));
}
@Test
void missingDisabledAndOptionalDependenciesAreReportedPrecisely() throws Exception {
Path plugins = temporary.resolve("plugins");
jar(plugins.resolve("islands.jar"), Map.of("islands/Main.java", plugin("islands", "", "")),
descriptor("islands", "islands.Main", "economy"), List.of());
assertEquals("Plugin islands requires economy, which is not installed",
loadFailure(plugins, enabled("islands")).getMessage());
jar(plugins.resolve("economy.jar"), Map.of("economy/Main.java", plugin("economy", "", "")),
descriptor("economy", "economy.Main"), List.of());
assertEquals("Plugin islands requires economy, which is installed but not enabled in plugins.enabled",
loadFailure(plugins, enabled("islands")).getMessage());
Path optional = temporary.resolve("optional");
jar(optional.resolve("chat.jar"), Map.of("chat/Main.java", plugin("chat", "", "")),
"id: chat\nversion: 1.0.0\nmain: chat.Main\ndependencies:\n - id: luckperms\n optional: true\n",
List.of());
try (var host = host()) {
host.loadPlugins(optional, enabled("chat"));
host.enable();
}
assertTrue(log.contains("chat enable"));
}
@Test
void cyclesDuplicateIdsAndDoubleEnablingFailStartup() throws Exception {
Path cycle = temporary.resolve("cycle");
jar(cycle.resolve("a.jar"), Map.of("a/Main.java", plugin("a", "", "")), descriptor("a", "a.Main", "b"), List.of());
jar(cycle.resolve("b.jar"), Map.of("b/Main.java", plugin("b", "", "")), descriptor("b", "b.Main", "a"), List.of());
assertEquals("Plugin dependency cycle: a -> b -> a", loadFailure(cycle, enabled("a", "b")).getMessage());
Path duplicate = temporary.resolve("duplicate");
jar(duplicate.resolve("one.jar"), Map.of("one/Main.java", plugin("one", "", "")), descriptor("same", "one.Main"), List.of());
jar(duplicate.resolve("two.jar"), Map.of("two/Main.java", plugin("two", "", "")), descriptor("same", "two.Main"), List.of());
assertEquals("Plugin id same is used by both one.jar and two.jar",
loadFailure(duplicate, enabled("same")).getMessage());
Path twice = temporary.resolve("twice");
jar(twice.resolve("economy.jar"), Map.of("economy/Main.java", plugin("economy", "", "")),
descriptor("economy", "economy.Main"), List.of());
assertEquals("Plugin economy is enabled twice, by id and by class name economy.Main",
loadFailure(twice, enabled("economy", "economy.Main")).getMessage());
}
@Test
void invalidDescriptorsNameTheJarAndTheProblem() throws Exception {
Path plugins = temporary.resolve("plugins");
jar(plugins.resolve("broken.jar"), Map.of("broken/Main.java", plugin("broken", "", "")),
"id: Broken\nversion: 1.0.0\nmain: broken.Main\n", List.of());
var failure = loadFailure(plugins, enabled("broken.Main"));
assertTrue(failure.getMessage().startsWith("Invalid META-INF/moonbridge-plugin.yml in broken.jar"),
failure.getMessage());
assertTrue(failure.getMessage().contains("id must match"), failure.getMessage());
}
private IOException loadFailure(Path plugins, Map<String, Map<String, String>> enabled) {
return assertThrows(IOException.class, () -> {
try (var host = host()) {
host.loadPlugins(plugins, enabled);
}
});
}
private static String causes(Throwable failure) {
var messages = new ArrayList<String>();
for (Throwable current = failure; current != null; current = current.getCause()) {
messages.add(String.valueOf(current.getMessage()));
}
return String.join(" <- ", messages);
}
private String property(String name) {
String key = "moonbridge.test." + name + "." + UUID.randomUUID();
propertyKeys.add(key);
return key;
}
private Map<String, Map<String, String>> enabled(String... keys) {
var enabled = new LinkedHashMap<String, Map<String, String>>();
for (String key : keys) enabled.put(key, Map.of("log", logKey));
return enabled;
}
private static Map<String, String> descriptor(String id, String main, String... dependencies) {
var text = new StringBuilder("id: " + id + "\nversion: 1.0.0\nmain: " + main + "\n");
if (dependencies.length > 0) {
text.append("dependencies:\n");
for (String dependency : dependencies) text.append(" - id: ").append(dependency).append('\n');
}
return Map.of(PluginDescriptor.RESOURCE, text.toString());
}
private Path jar(Path jar, Map<String, String> sources, String descriptor, List<Path> compileAgainst)
throws IOException {
return jar(jar, sources, Map.of(PluginDescriptor.RESOURCE, descriptor), compileAgainst);
}
private Path jar(Path jar, Map<String, String> sources, Map<String, String> resources, List<Path> compileAgainst)
throws IOException {
return TestPluginJars.build(temporary, jar, sources, resources, compileAgainst);
}
/** A plugin {@code <pkg>.Main} that logs its lifecycle and runs extra code in onLoad and onEnable. */
private static String plugin(String pkg, String onLoad, String onEnable) {
return """
package %s;
import dev.moonbridge.api.*;
import java.util.List;
public final class Main implements Plugin {
private PluginContext context;
@SuppressWarnings("unchecked")
private List<String> log() {
return (List<String>) System.getProperties().get(context.settings().get("log"));
}
@Override public void onLoad(PluginContext context) {
this.context = context;
log().add("%s load");
%s
}
@Override public void onEnable() {
log().add("%s enable");
%s
}
@Override public void onDisable() { log().add("%s disable"); }
}
""".formatted(pkg, pkg, onLoad, pkg, onEnable, pkg);
}
private static PluginHost host() {
return new PluginHost(new InMemoryBackendCatalog(), new Players() {
@Override public Optional<PlayerView> find(PlayerIdentity identity) { return Optional.empty(); }
@Override public List<PlayerView> online() { return List.of(); }
@Override public CompletableFuture<TransferResult> transfer(PlayerIdentity identity, String backend) {
return CompletableFuture.completedFuture(TransferResult.failed("test"));
}
@Override public CompletableFuture<MessageResult> sendMessage(PlayerIdentity identity, Component message) {
return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED);
}
@Override public CompletableFuture<DisconnectResult> disconnect(PlayerIdentity identity, Component reason) {
return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED);
}
}, Duration.ofSeconds(1));
}
}
@@ -0,0 +1,102 @@
package dev.moonbridge.core.plugin;
import dev.moonbridge.api.Plugin;
import dev.moonbridge.api.PluginContext;
import dev.moonbridge.messaging.Messaging;
import net.kyori.adventure.text.Component;
import org.slf4j.Logger;
import javax.tools.DiagnosticCollector;
import javax.tools.JavaFileObject;
import javax.tools.ToolProvider;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
/** Compiles Java sources into a plugin JAR for tests that need real plugin class loaders. */
final class TestPluginJars {
private TestPluginJars() { }
/**
* Writes {@code sources} (path inside the source tree to Java text), compiles them against the test classpath
* plus {@code compileAgainst} (class directories of other test plugins) and packs them with {@code resources}.
* Returns the class directory, so later plugins can compile against this one.
*/
static Path build(Path workDirectory, Path jar, Map<String, String> sources, Map<String, String> resources,
List<Path> compileAgainst) throws IOException {
Path sourceRoot = workDirectory.resolve("src-" + UUID.randomUUID());
Path classes = workDirectory.resolve("classes-" + UUID.randomUUID());
Files.createDirectories(classes);
List<Path> files = new ArrayList<>();
for (var source : sources.entrySet()) {
Path file = sourceRoot.resolve(source.getKey());
Files.createDirectories(file.getParent());
Files.writeString(file, source.getValue());
files.add(file);
}
compile(files, classes, compileAgainst);
Files.createDirectories(jar.getParent());
try (var output = new JarOutputStream(Files.newOutputStream(jar))) {
try (var walk = Files.walk(classes)) {
for (Path file : walk.filter(Files::isRegularFile).toList()) {
output.putNextEntry(new JarEntry(classes.relativize(file).toString().replace('\\', '/')));
Files.copy(file, output);
output.closeEntry();
}
}
for (var resource : resources.entrySet()) {
output.putNextEntry(new JarEntry(resource.getKey()));
output.write(resource.getValue().getBytes(StandardCharsets.UTF_8));
output.closeEntry();
}
}
return classes;
}
private static void compile(List<Path> sources, Path output, List<Path> compileAgainst) throws IOException {
var compiler = ToolProvider.getSystemJavaCompiler();
assertNotNull(compiler, "Tests require a JDK with the Java compiler");
var diagnostics = new DiagnosticCollector<JavaFileObject>();
var classpath = new ArrayList<>(List.of(testClasspath()));
compileAgainst.forEach(path -> classpath.add(path.toString()));
List<String> arguments = List.of("--release", "25", "-classpath",
String.join(java.io.File.pathSeparator, classpath), "-d", output.toString());
try (var fileManager = compiler.getStandardFileManager(diagnostics, null, null)) {
var units = fileManager.getJavaFileObjectsFromPaths(sources);
boolean compiled = Boolean.TRUE.equals(compiler.getTask(null, fileManager, diagnostics,
arguments, null, units).call());
assertTrue(compiled, () -> diagnostics.getDiagnostics().stream()
.map(Object::toString).collect(Collectors.joining("\n")));
}
}
private static String testClasspath() {
var entries = new LinkedHashSet<Path>();
for (String entry : System.getProperty("java.class.path", "").split(
java.util.regex.Pattern.quote(java.io.File.pathSeparator))) {
if (!entry.isBlank()) entries.add(Path.of(entry).toAbsolutePath().normalize());
}
for (Class<?> type : List.of(Plugin.class, PluginContext.class, Messaging.class, Logger.class, Component.class)) {
try {
var source = type.getProtectionDomain().getCodeSource();
if (source != null) entries.add(Path.of(source.getLocation().toURI()));
} catch (URISyntaxException | NullPointerException ignored) {
// The regular test classpath may already contain this dependency.
}
}
return entries.stream().map(Path::toString).collect(Collectors.joining(java.io.File.pathSeparator));
}
}
+6
View File
@@ -105,6 +105,12 @@ tasks.named<ShadowJar>("shadowJar") {
}
}
tasks.processResources {
val pluginVersion = project.version.toString()
inputs.property("pluginVersion", pluginVersion)
filesMatching("META-INF/moonbridge-plugin.yml") { expand("version" to pluginVersion) }
}
tasks.jar { enabled = false }
val pluginJar = tasks.register<Jar>("pluginJar") {
@@ -0,0 +1,4 @@
# Lets other plugins declare `dependencies: [{ id: luckperms }]`; see docs/plugins.md.
id: luckperms
version: "${version}"
main: dev.moonbridge.luckperms.LuckPermsMoonBridgePlugin
@@ -22,6 +22,12 @@ public interface PluginContext {
Events events();
/** Services this plugin offers to its dependents, and those its dependencies offer to it. */
PluginServices services();
/** The plugins loaded with a descriptor, including this one's own. */
Plugins plugins();
/** Returns this plugin's lifecycle-owned message channel scope. */
Messaging messaging();
@@ -0,0 +1,18 @@
package dev.moonbridge.api;
import java.util.List;
import java.util.Objects;
/**
* A plugin that declared itself in {@code META-INF/moonbridge-plugin.yml}. Plugins without a descriptor have no id,
* so other plugins can neither find them here nor depend on them.
*
* @param dependencies ids this plugin declared, required and optional, in declaration order
*/
public record PluginInfo(String id, String version, List<String> dependencies) {
public PluginInfo {
Objects.requireNonNull(id, "id");
Objects.requireNonNull(version, "version");
dependencies = List.copyOf(dependencies);
}
}
@@ -0,0 +1,26 @@
package dev.moonbridge.api;
import java.util.Optional;
/**
* Typed services one plugin offers to the plugins that depend on it. A provider registers an implementation of a type
* it ships, usually an interface; a plugin that declares the provider in its {@code dependencies} can load that type
* and look the service up. Lookups only see the plugin's own services and those of its declared dependencies,
* directly or through other dependencies, so a service can never be reached by accident.
*
* <p>Each type has at most one provider. A provider's services are removed when it is disabled; dependents are
* disabled before their dependencies, so a dependent never observes its provider's services disappearing.
*/
public interface PluginServices {
/** Offers {@code service} as {@code type}; fails if another plugin already provides {@code type}. */
<T> ServiceRegistration register(Class<T> type, T service);
/** The service registered for {@code type} by this plugin or one of its dependencies. */
<T> Optional<T> find(Class<T> type);
/** Like {@link #find}, but fails with a message naming the type when no visible plugin provides it. */
default <T> T require(Class<T> type) {
return find(type).orElseThrow(() -> new IllegalStateException(
"No plugin this one depends on provides " + type.getName()));
}
}
@@ -0,0 +1,14 @@
package dev.moonbridge.api;
import java.util.List;
import java.util.Optional;
/** The plugins the proxy loaded with a descriptor, in load order. */
public interface Plugins {
/** This plugin's own descriptor; empty for a plugin loaded without {@code moonbridge-plugin.yml}. */
Optional<PluginInfo> self();
Optional<PluginInfo> find(String id);
List<PluginInfo> all();
}
@@ -0,0 +1,9 @@
package dev.moonbridge.api;
/** Handle for a service a plugin registered. Disabling the plugin unregisters it as well. */
public interface ServiceRegistration extends AutoCloseable {
/** Withdraws the service; later lookups no longer find it. Repeated calls have no effect. */
void unregister();
@Override default void close() { unregister(); }
}