mirror of
https://github.com/layue13/MoonBridge.git
synced 2026-10-25 18:46:18 +00:00
Merge pull request #8 from layue13/feat/plugin-dependencies
feat: 插件之间的依赖、类共享与类型化服务
This commit is contained in:
19 files changed
+960
-41
No files matched your search
@@ -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
@@ -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 一致。
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
}
|
||||
Reference in new issue
Block a user