diff --git a/CLAUDE.md b/CLAUDE.md index 7d7bb7a..c824f3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/docs/permissions.md b/docs/permissions.md index 85c4016..7583efa 100644 --- a/docs/permissions.md +++ b/docs/permissions.md @@ -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 一致。 diff --git a/docs/plugins.md b/docs/plugins.md index 80d3205..855f105 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -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 和本次连接代次;异步转服要保留这个完整身份,不能用旧连接去操作重连后的玩家。 diff --git a/proxy/core/src/main/java/dev/moonbridge/core/plugin/HostServices.java b/proxy/core/src/main/java/dev/moonbridge/core/plugin/HostServices.java index 9377730..e3f54db 100644 --- a/proxy/core/src/main/java/dev/moonbridge/core/plugin/HostServices.java +++ b/proxy/core/src/main/java/dev/moonbridge/core/plugin/HostServices.java @@ -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> pluginInfos) { } diff --git a/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginClassLoader.java b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginClassLoader.java index 82604da..f87f0fe 100644 --- a/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginClassLoader.java +++ b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginClassLoader.java @@ -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 libraries = new HashSet<>(); + private final List dependencies; PluginClassLoader(java.net.URL[] urls, ClassLoader parent) { + this(urls, parent, List.of()); + } + + PluginClassLoader(java.net.URL[] urls, ClassLoader parent, List 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 { diff --git a/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginContextImpl.java b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginContextImpl.java index e56a307..edcf6cc 100644 --- a/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginContextImpl.java +++ b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginContextImpl.java @@ -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 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 dependencyIds; + private final PluginServices pluginServices; + private final Plugins pluginDirectory; final Set> eventSubscriptions = new HashSet<>(); volatile boolean active = true; private boolean eventRegistrationOpen; PluginContextImpl(HostServices host, BackendOwner owner, Map settings, Path dataDirectory, PluginClassLoader pluginClassLoader) { + this(host, owner, settings, dataDirectory, pluginClassLoader, null, Set.of()); + } + + PluginContextImpl(HostServices host, BackendOwner owner, Map settings, Path dataDirectory, + PluginClassLoader pluginClassLoader, PluginInfo info, Set dependencyIds) { this.host = host; + this.info = info; + this.dependencyIds = Set.copyOf(dependencyIds); + this.pluginServices = new PluginServices() { + @Override public ServiceRegistration register(Class type, T service) { + synchronized (PluginContextImpl.this) { + requireActive(); + return host.pluginServices().register(PluginContextImpl.this, type, service); + } + } + @Override public Optional find(Class type) { + synchronized (PluginContextImpl.this) { + requireActive(); + return host.pluginServices().find(PluginContextImpl.this, type); + } + } + }; + this.pluginDirectory = new Plugins() { + @Override public Optional self() { return Optional.ofNullable(info); } + @Override public Optional find(String id) { + Objects.requireNonNull(id, "id"); + return all().stream().filter(plugin -> plugin.id().equals(id)).findFirst(); + } + @Override public List 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); diff --git a/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginDescriptor.java b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginDescriptor.java new file mode 100644 index 0000000..9c225c5 --- /dev/null +++ b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginDescriptor.java @@ -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 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(); + 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 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; + } +} diff --git a/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginGraph.java b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginGraph.java new file mode 100644 index 0000000..36c3491 --- /dev/null +++ b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginGraph.java @@ -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 List order(List items, Function id, Function> dependsOn) { + var remaining = new ArrayList<>(items); + var placed = new HashSet(); + var ordered = new ArrayList(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 reachable(String start, Function> dependsOn) { + var seen = new LinkedHashSet(); + 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 String cycle(List remaining, Function id, Function> dependsOn) { + Map byId = new LinkedHashMap<>(); + for (T item : remaining) if (id.apply(item) != null) byId.put(id.apply(item), item); + var path = new ArrayList(); + 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); + } +} diff --git a/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginHost.java b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginHost.java index a5f6e1c..4d8a664 100644 --- a/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginHost.java +++ b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginHost.java @@ -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,7 +31,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; -import java.util.ServiceLoader; +import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.CompletionStage; import java.util.concurrent.ExecutionException; @@ -66,6 +67,9 @@ public final class PluginHost implements AutoCloseable, EventDispatcher { private final HostServices services; private final List classLoaders = new ArrayList<>(); private final List plugins = new ArrayList<>(); + private final PluginServiceRegistry pluginServices = new PluginServiceRegistry(); + /** Descriptor plugins in load order, readable by plugins on any thread. */ + private volatile List pluginInfos = List.of(); private volatile BackendChannelTransport backendChannelTransport; public PluginHost(BackendCatalog catalog, Players players, Duration placementTimeout) { @@ -144,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) { @@ -153,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> enabled) throws IOException { lifecycle.require(HostLifecycle.State.LOADING); Objects.requireNonNull(directory, "directory"); @@ -170,42 +178,124 @@ public final class PluginHost implements AutoCloseable, EventDispatcher { if (!Files.isDirectory(directory)) { throw new IOException("Plugin path is not a directory: " + directory); } - List jars = PluginJars.list(directory); - var remaining = enabled == null ? null : new HashSet<>(enabled.keySet()); - for (Path jar : jars) { - try { - List 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) { - ServiceLoader.load(Plugin.class, loader).stream().forEach(provider -> { - String className = provider.type().getName(); - loadOne(provider.get(), 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 = Class.forName(className, true, loader).asSubclass(Plugin.class) - .getConstructor().newInstance(); - loadOne(plugin, jar.getFileName() + ":" + className, enabled.get(className), - directory.resolve("data").resolve(className), loader); - } + try { + List ordered = resolve(installedCandidates(directory), enabled); + Map loaders = new java.util.HashMap<>(); + Map 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 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 = Class.forName(candidate.className(), true, loader).asSubclass(Plugin.class) + .getConstructor().newInstance(); + } catch (ReflectiveOperationException | ClassCastException | LinkageError failure) { + throw new IOException("Failed to load plugin JAR " + candidate.jar(), failure); + } + Set 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 settings, + List presentDependencies) { + String id() { return descriptor == null ? null : descriptor.id(); } + } + + private static List installedCandidates(Path directory) throws IOException { + List installed = new ArrayList<>(); + Map ids = new java.util.HashMap<>(); + for (Path jar : PluginJars.list(directory)) { + Optional 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 resolve(List installed, Map> enabled) + throws IOException { + var remaining = enabled == null ? null : new HashSet<>(enabled.keySet()); + List selected = new ArrayList<>(); + for (Candidate candidate : installed) { + Map 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 installedIds = new HashSet<>(); + installed.forEach(candidate -> { if (candidate.id() != null) installedIds.add(candidate.id()); }); + Set selectedIds = new HashSet<>(); + selected.forEach(candidate -> { if (candidate.id() != null) selectedIds.add(candidate.id()); }); + List withDependencies = new ArrayList<>(); + for (Candidate candidate : selected) { + List 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. */ @@ -215,7 +305,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(); @@ -240,7 +330,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(); @@ -378,7 +468,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()); @@ -391,7 +481,7 @@ public final class PluginHost implements AutoCloseable, EventDispatcher { } private void loadOne(Plugin plugin, String id, Map settings, Path dataDirectory, - PluginClassLoader pluginClassLoader) { + PluginClassLoader pluginClassLoader, PluginInfo info, Set dependencyIds) { long generation = NEXT_HOST_GENERATION.updateAndGet(value -> { if (value == Long.MAX_VALUE) { throw new IllegalStateException("Plugin host generation exhausted"); @@ -399,15 +489,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(); diff --git a/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginServiceRegistry.java b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginServiceRegistry.java new file mode 100644 index 0000000..e1a574a --- /dev/null +++ b/proxy/core/src/main/java/dev/moonbridge/core/plugin/PluginServiceRegistry.java @@ -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, Entry> entries = new ConcurrentHashMap<>(); + + ServiceRegistration register(PluginContextImpl owner, Class 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); + } + + Optional find(PluginContextImpl caller, Class 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); + } +} diff --git a/proxy/core/src/test/java/dev/moonbridge/core/plugin/PluginDependencyTest.java b/proxy/core/src/test/java/dev/moonbridge/core/plugin/PluginDependencyTest.java new file mode 100644 index 0000000..cdbbd85 --- /dev/null +++ b/proxy/core/src/test/java/dev/moonbridge/core/plugin/PluginDependencyTest.java @@ -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 log = Collections.synchronizedList(new ArrayList<>()); + private final List 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> enabled) { + return assertThrows(IOException.class, () -> { + try (var host = host()) { + host.loadPlugins(plugins, enabled); + } + }); + } + + private static String causes(Throwable failure) { + var messages = new ArrayList(); + 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> enabled(String... keys) { + var enabled = new LinkedHashMap>(); + for (String key : keys) enabled.put(key, Map.of("log", logKey)); + return enabled; + } + + private static Map 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 sources, String descriptor, List compileAgainst) + throws IOException { + return jar(jar, sources, Map.of(PluginDescriptor.RESOURCE, descriptor), compileAgainst); + } + + private Path jar(Path jar, Map sources, Map resources, List compileAgainst) + throws IOException { + return TestPluginJars.build(temporary, jar, sources, resources, compileAgainst); + } + + /** A plugin {@code .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 log() { + return (List) 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 find(PlayerIdentity identity) { return Optional.empty(); } + @Override public List online() { return List.of(); } + @Override public CompletableFuture transfer(PlayerIdentity identity, String backend) { + return CompletableFuture.completedFuture(TransferResult.failed("test")); + } + @Override public CompletableFuture sendMessage(PlayerIdentity identity, Component message) { + return CompletableFuture.completedFuture(MessageResult.NOT_CONNECTED); + } + @Override public CompletableFuture disconnect(PlayerIdentity identity, Component reason) { + return CompletableFuture.completedFuture(DisconnectResult.NOT_CONNECTED); + } + }, Duration.ofSeconds(1)); + } +} diff --git a/proxy/core/src/test/java/dev/moonbridge/core/plugin/TestPluginJars.java b/proxy/core/src/test/java/dev/moonbridge/core/plugin/TestPluginJars.java new file mode 100644 index 0000000..ce329cc --- /dev/null +++ b/proxy/core/src/test/java/dev/moonbridge/core/plugin/TestPluginJars.java @@ -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 sources, Map resources, + List compileAgainst) throws IOException { + Path sourceRoot = workDirectory.resolve("src-" + UUID.randomUUID()); + Path classes = workDirectory.resolve("classes-" + UUID.randomUUID()); + Files.createDirectories(classes); + List 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 sources, Path output, List compileAgainst) throws IOException { + var compiler = ToolProvider.getSystemJavaCompiler(); + assertNotNull(compiler, "Tests require a JDK with the Java compiler"); + var diagnostics = new DiagnosticCollector(); + var classpath = new ArrayList<>(List.of(testClasspath())); + compileAgainst.forEach(path -> classpath.add(path.toString())); + List 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(); + 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)); + } +} diff --git a/proxy/luckperms/build.gradle.kts b/proxy/luckperms/build.gradle.kts index db9b9a9..3a2a2db 100644 --- a/proxy/luckperms/build.gradle.kts +++ b/proxy/luckperms/build.gradle.kts @@ -105,6 +105,12 @@ tasks.named("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("pluginJar") { diff --git a/proxy/luckperms/src/main/resources/META-INF/moonbridge-plugin.yml b/proxy/luckperms/src/main/resources/META-INF/moonbridge-plugin.yml new file mode 100644 index 0000000..b6e4aa3 --- /dev/null +++ b/proxy/luckperms/src/main/resources/META-INF/moonbridge-plugin.yml @@ -0,0 +1,4 @@ +# Lets other plugins declare `dependencies: [{ id: luckperms }]`; see docs/plugins.md. +id: luckperms +version: "${version}" +main: dev.moonbridge.luckperms.LuckPermsMoonBridgePlugin diff --git a/proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginContext.java b/proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginContext.java index f1ab143..7ac3c87 100644 --- a/proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginContext.java +++ b/proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginContext.java @@ -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(); diff --git a/proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginInfo.java b/proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginInfo.java new file mode 100644 index 0000000..d97938b --- /dev/null +++ b/proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginInfo.java @@ -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 dependencies) { + public PluginInfo { + Objects.requireNonNull(id, "id"); + Objects.requireNonNull(version, "version"); + dependencies = List.copyOf(dependencies); + } +} diff --git a/proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginServices.java b/proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginServices.java new file mode 100644 index 0000000..4ced329 --- /dev/null +++ b/proxy/plugin-api/src/main/java/dev/moonbridge/api/PluginServices.java @@ -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. + * + *

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}. */ + ServiceRegistration register(Class type, T service); + + /** The service registered for {@code type} by this plugin or one of its dependencies. */ + Optional find(Class type); + + /** Like {@link #find}, but fails with a message naming the type when no visible plugin provides it. */ + default T require(Class type) { + return find(type).orElseThrow(() -> new IllegalStateException( + "No plugin this one depends on provides " + type.getName())); + } +} diff --git a/proxy/plugin-api/src/main/java/dev/moonbridge/api/Plugins.java b/proxy/plugin-api/src/main/java/dev/moonbridge/api/Plugins.java new file mode 100644 index 0000000..2c5b019 --- /dev/null +++ b/proxy/plugin-api/src/main/java/dev/moonbridge/api/Plugins.java @@ -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 self(); + + Optional find(String id); + + List all(); +} diff --git a/proxy/plugin-api/src/main/java/dev/moonbridge/api/ServiceRegistration.java b/proxy/plugin-api/src/main/java/dev/moonbridge/api/ServiceRegistration.java new file mode 100644 index 0000000..1b7edcb --- /dev/null +++ b/proxy/plugin-api/src/main/java/dev/moonbridge/api/ServiceRegistration.java @@ -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(); } +}