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 bundled plugins inherit their version from the repository's parent POM (<parent> with
marginalia-parent); a plugin of your own outside the repository drops that <parent> and gives its <version>
itself. 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.5.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. The one exception: the data providers registered withIPCService, the tool providers (with their commands) registered withToolServiceand the settings registered withInheritedSettingRegistry(Inherited settings) under the symbolic name of the bundle are removed by the application when the extension is unloaded (Sharing data between extensions).
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. When the folder holds several versions of one extension (same symbolic name, e.g. an update copied next to the old JAR), only the newest is started; a warning names the skipped JARs, which are moved toextensions/old-versions(not deleted). 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.OsgiService.getInvalidExtensions()lists the installed, not stopped bundles that have a.invalidreport; the workspaces show them in the redInvalidExtensionsWarningbar (ui.components) at the top (the administrator gets the names as its tooltip, other users a note to tell the administrator). A bundle whose JAR has a<jar name>.STOPPEDflag next to it (see Stopping) is installed but not started either. -
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 (state Installed or Resolved) and can be unloaded the same way. -
Stopping. Stop (
OsgiService.stopExtension, administrators only) creates the<jar name>.STOPPEDflag file, unloads the extension like Unload does (bound callbacks,onExtensionUnload) and stops the bundle; the bundle stays installed and is listed with the state Stopped (isStopped). Start (startExtension) verifies it, starts it and deletes the flag; when that fails, the flag stays. The flag survives restarts. Uploading a replacement of a stopped extension deletes the flag, and Unload deletes the flag too. -
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.
Logging
An extension should not use LoggerFactory (its lines are mixed with everything else and the administrator cannot
see them next to the extension). OsgiService.createLogger(bundle) returns an org.slf4j.Logger that writes to its own file,
<jar name>.log in the extensions folder (sidequery.jar.log), which administrators read in Admin → Extensions →
Log:
private Logger log;
@Override
public void onExtensionLoad(Bundle bundle, OsgiService parentService, ExtensionService extensionService) {
log = parentService.createLogger(bundle); // or createLogger(bundle, "my.name")
log.info("Loaded {} {}", bundle.getSymbolicName(), bundle.getVersion());
}
-
The usual SLF4J API:
{}placeholders and a trailing throwable (logged with its stack trace). TRACE is dropped, DEBUG and above are written. Each line has the time, level, thread, logger name (the symbolic name by default) and the message. The line is also chained to the application log (loggerextension.<name>, same level, so the level settings of the application apply - the release build logs at INFO and keeps story content out of its log, so don't log story text above DEBUG). -
The logger is thread safe and can be kept; the file is opened for every line, so nothing stays open after the extension is gone. A file over 2 MB is moved to
<jar name>.log.old(replacing the previous one). -
OsgiService.readLog(bundle, maxBytes)andclearLog(bundle)(administrators only) read the end of the file and delete it. The log is deleted with the extension when it is unloaded. -
Code that is not created by the extension class (components made by
new, services) needs the logger too: pass it on (Lore Generator gives it to its service) or keep it in a static holder that is set inonExtensionLoadand reset inonExtensionUnload, withLoggerFactoryas the fallback before and after (Side Query'sSideQueryLog). -
The class
org.slf4j.Loggercomes from the application (boot delegation); if your bundle lists itsImport-Packageexplicitly, includeorg.slf4j.
Extension events
OsgiService reports single extensions that are loaded or unloaded to ExtensionListeners, for code that has to
react to the extension set - the application itself uses it to keep the styles of extensions in the pages
(Styles of a plugin):
osgiService.addExtensionListener(listener); // held weakly: the subscriber keeps the reference
osgiService.removeExtensionListener(listener);
osgiService.getLoadedExtensions(); // the extensions that are loaded now: (Bundle, MarginaliaExtension)
-
extensionLoaded(bundle, extension)is called afteronExtensionLoadreturned. An extension whose load failed is never reported (and not as unloaded either); it is unloaded again as before. -
extensionUnloaded(bundle, extension)is called afteronExtensionUnload, for every extension that was reported as loaded: Unload, an update that replaces the bundle, the shutdown of the application. -
The methods run on the thread that loads or unloads (an administrator's request, the startup), with no UI session. Change a UI through
ui.access(...). An exception is logged and doesn't stop other listeners or the loading. -
Listeners get no events for extensions that are loaded already. Subscribe first, then read
getLoadedExtensions(); an extension loaded between the two is seen twice, so handle repeats (CssRegistry.attachdoes).
Optional interfaces. Besides MarginaliaExtension, the extension class can implement ExtensionHasCss (styles
put into every page, see Styles of a plugin) and ExtensionHasLocalization
(languages for the UI: OsgiServiceImpl registers the returned LocalizationBundles in Localization right after
onExtensionLoad returned and removes them when the extension is unloaded; see
Example plugin).
The older ExtensionObserver (addSubscriber) reports the start and stop of the whole framework, including the
pauses during an upload or unload.
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. A plugin that uses the objects of another one (which may be missing, or unloaded and installed again) finds it among
OsgiService.getLoadedExtensions()by the name of its class and calls it by reflection, taking the types it needs fromextension.getClass().getClassLoader(). Do not import its packages (Import-Package,DynamicImport-Package) or name its classes in your code: the wiring is to one revision of the other bundle, so after it is unloaded and installed again your bundle keeps the old classes and does not recognize the new objects, and the JVM remembers a class reference that failed to resolve (NoClassDefFoundError), so a reference used while the other bundle was missing keeps failing after it is installed. For tools for the model useToolServiceinstead, no reflection is needed (Sharing data between extensions). -
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.