Plugin basics
This page covers what every plugin needs: the Maven project, the bundle activator, the MarginaliaExtension and how
Marginalia loads, starts and unloads it.
Project layout
A plugin is an ordinary Maven project with bundle packaging:
myplugin/
├── pom.xml
└── src/main/java/com/example/marginalia/myplugin/
├── MyPluginActivator.java BundleActivator - registers the extension
├── MyPluginExtension.java MarginaliaExtension - registers hooks, cleans up
├── model/ data classes stored as JSON in attributes (optional)
├── service/ plugin logic, @Configurable (optional)
└── ui/ Vaadin components (optional)
Use your own package. The bundled plugins live under com.github.enerccio.marginalia.extensions.<name>; a third-party
plugin should not.
pom.xml
The easiest start is to copy the pom.xml of marginalia/plugins/chaptermarker and change the coordinates and the
package names. The important parts:
<packaging>bundle</packaging>
<dependencies>
<!-- the application's classes, installed by `mvn install` in marginalia/ -->
<dependency>
<groupId>io.github.enerccio</groupId>
<artifactId>marginalia</artifactId>
<version>1.0.0</version>
<classifier>classes</classifier>
<scope>provided</scope>
</dependency>
<!-- plus, all `provided`: org.apache.felix.framework, vaadin-core, and any library
of the application you use directly (gson, commons-lang3, font-awesome-iron-iconset...) -->
</dependencies>
<build>
<plugins>
<!-- maven-compiler-plugin, release 25 -->
<!-- aspectj-maven-plugin with spring-aspects as aspect library - needed for @Configurable -->
<plugin>
<groupId>org.apache.felix</groupId>
<artifactId>maven-bundle-plugin</artifactId>
<version>6.2.0</version>
<extensions>true</extensions>
<configuration>
<instructions>
<Bundle-SymbolicName>${project.artifactId}</Bundle-SymbolicName>
<Bundle-Version>${project.version}</Bundle-Version>
<Bundle-Activator>com.example.marginalia.myplugin.MyPluginActivator</Bundle-Activator>
<Import-Package>!com.example.marginalia.myplugin.*</Import-Package>
<Export-Package>com.example.marginalia.myplugin.*</Export-Package>
</instructions>
</configuration>
</plugin>
</plugins>
</build>
-
Everything from the application is
provided. Marginalia runs the bundle with boot delegation for all packages (seeClass loading ), so the bundle sees exactly the classes and libraries of the running WAR. Bundling your own copy of Vaadin, Spring or Gson would create a second, incompatible copy of their classes. -
Versions must match the application. Take the Vaadin, Gson and commons versions from
marginalia/pom.xml. Compiling against a different Vaadin version than the one that runs usually works until it doesn't. -
Import-Packageimports nothing. The single negated entry stops bnd from generatingImport-Packageheaders - they are not needed with boot delegation and would make the bundle fail to resolve. -
AspectJ weaves Spring's
AnnotationBeanConfigurerAspectinto the plugin's classes, so a plugin class annotated@Configurablegets its@Autowiredfields injected from the application's Spring context, like the application's own UI classes (AspectJ weaving). Without it, those fields staynull. -
Libraries the application doesn't have must be embedded in the bundle (
Embed-Dependency, commented out in the Reviewer'spom.xml) or shaded. Keep this to a minimum.
Build with mvn package; the result is target/<artifactId>-<version>.jar. The application must be installed in the
local Maven repository first (mvn install -DskipTests in marginalia/), and reinstalled whenever you change
classes the plugin uses.
The activator
The bundle's activator registers one MarginaliaExtension as an OSGi service. That's all it should do - Marginalia
discovers the service and calls it:
public class MyPluginActivator implements BundleActivator {
private ServiceRegistration<MarginaliaExtension> registration;
@Override
public void start(BundleContext context) {
registration = context.registerService(MarginaliaExtension.class, new MyPluginExtension(), null);
}
@Override
public void stop(BundleContext context) {
if (registration != null) {
registration.unregister();
registration = null;
}
}
}
Only bundles that register a MarginaliaExtension are listed in Admin → Extensions.
The extension
public interface MarginaliaExtension {
void onExtensionLoad(Bundle bundle, OsgiService parentService, ExtensionService extensionService);
void onExtensionUnload(Bundle b, OsgiService osgiService, ExtensionService extensionService);
}
-
onExtensionLoadregisters everything the plugin needs: decorators (extensionService.registerDecorator(...), see @Extendable hooks), generation listeners (storyGenerationService.addEventListener(...), see Generation events), cleanup contributors (Extended attributes). It should not build UI - there is no UI at that moment. UI is added later, from decorators, when the user opens the screen the plugin extends. -
onExtensionUnloadundoes all of it: unregister every decorator and listener, remove the components the plugin added to open screens (Unloading), stop background work. Nothing is removed automatically - a decorator left registered keeps running, from a bundle that no longer exists.
Annotate the extension @Configurable and inject what you need:
@Configurable
public class MyPluginExtension implements MarginaliaExtension {
@Autowired
private Localization loc;
@Autowired
private ChatMessageService chatMessageService;
@Autowired
private StoryGenerationService storyGenerationService;
...
}
Any bean of the application can be injected - every service in domain/service (Services),
Localization, InferenceServices, Configuration, OsgiService... Plugin classes created with new (services,
dialogs, forms) can be @Configurable too; the bundled plugins create their service object in the extension
(new ReviewerService()) and it is injected the same way.
Errors in onExtensionLoad
At startup onExtensionLoad runs without a UI, so UIUtils.internalServerError(...) (which opens a dialog) can't
show anything. Throw instead: an exception that escapes onExtensionLoad is logged, onExtensionUnload is called to
undo what was registered so far and the bundle is stopped. The other extensions load normally; an upload shows the
error to the administrator.
Lifecycle
sequenceDiagram
participant S as Spring (root context)
participant O as OsgiServiceImpl
participant F as Felix
participant A as Activator
participant E as MarginaliaExtension
S->>O: ContextRefreshedEvent
O->>F: start framework (storage extensions/org.eclipse.osgi, cleaned)
O->>F: installBundle(file:...) for every *.jar in extensions/
loop every bundle
O->>O: verify (reuse name.valid / name.invalid or run ExtensionService.verifyExtension)
O->>F: bundle.start() - only when valid
F->>A: start(context)
A->>F: registerService(MarginaliaExtension)
O->>E: onExtensionLoad(bundle, osgiService, extensionService)
end
Note over O,E: Admin → Extensions → Unload
O->>E: run bound component callbacks, then onExtensionUnload(...)
O->>F: bundle.uninstall() (stops it → Activator.stop), delete the JAR-
Startup.
OsgiServiceImpllistens for the root context'sContextRefreshedEvent, starts Felix with its storage in~/.marginalia/extensions/org.eclipse.osgi(wiped on every start, so the JARs are always read fresh), installs every*.jarin~/.marginalia/extensionsand starts them. For eachMarginaliaExtensionservice a bundle registered, it callsonExtensionLoad. Each JAR is installed and started on its own: one that can't be installed (not a bundle, a second copy of an installed plugin) or fails to start is logged with its file name and skipped. Before a bundle is started it is verified: the decorators it registers are checked against this version of the application (Verification before loading). An invalid extension stays installed but is not started, and its report is logged. -
Installing at runtime. Admin → Extensions → Load Extension (.jar) writes the uploaded file into
~/.marginalia/extensions(the file name is sanitized: only letters, digits,.,_and-) and installs and starts it the same way, verification included (OsgiService.installPackage, likeuninstallPackageadministrators only:AdminGuard.requireAdmin(); an invalid upload throwsExtensionVerificationExceptionwith the report). Screens that are already open are not rebuilt: the plugin's decorators run the next time the decorated methods run (the next time the user opens a book, a tab...). -
Updating. Uploading a bundle whose symbolic name (or file name) is already installed replaces it: the old one is unloaded as by Unload, uninstalled and its JAR deleted, then the new one is loaded. If the new one fails to install, verify or start, its JAR is deleted and the old one is put back.
-
Unloading. Unload calls the callbacks registered with
bindAttachableComponent, thenonExtensionUnload, uninstalls the bundle and deletes its JAR and its verification reports (OsgiService.uninstallPackage). Bundles that failed to start are listed too (stateINSTALLEDorRESOLVED) and can be unloaded the same way. -
Shutdown. When the application stops (the root context closes), every extension is unloaded the same way - bound callbacks,
onExtensionUnload, the activator'sstop- and the framework is stopped. Still save data as you go: a killed process runs none of this.
Class loading
Felix is started with org.osgi.framework.bootdelegation=* and the framework class loader as the bundle parent, which
is the web application's class loader. Every class a bundle asks for is therefore looked up in the application
first: Marginalia's own classes, Vaadin, Spring, Hibernate, Gson, everything in WEB-INF/lib. The consequences:
-
Plugins use the application's classes directly - no imports, no service lookups,
instanceofand casts work. -
A plugin can't override or replace a library of the application, and can't use a different version of one.
-
Plugins don't see each other's classes through imports either; if two plugins need to cooperate, do it through shared data (attributes) or through the application's components.
-
The plugin's own classes are loaded by the bundle's class loader, so the class names you pass to
registerDecoratormust be those of the application's classes; a plugin's own classes are never instrumented.
The development loop
-
Run Marginalia from your IDE or a local Jetty with a separate data folder (Running locally).
-
mvn packagethe plugin. -
Load the JAR in Admin → Extensions, or copy it into
<data folder>/extensionsand restart. -
Open the screen the plugin extends. If nothing happens, check the log: a decorator that can't find an argument, local variable or field logs
Mod '<class>' skipped on <class>.<method>: ...with the missing name; other exceptions are logged asUnhandled exception in extension .... -
To try a new build: Unload the plugin (it deletes the JAR), load the new one.
When a plugin uses a class or method of the application that changed, the plugin fails at runtime with a
NoSuchMethodError or NoClassDefFoundError. Rebuild it against the current application.