@Extendable hooks
Most of what a plugin does starts with a decorator: an object whose onMethodEnter and onMethodLeave run
before and after a method of an application class. Any method of a class annotated @Extendable can be decorated,
private ones included, and the decorator can read the method's arguments and local variables. This page explains how
that works and how to use it.
Instrumentation
flowchart LR
Init["RuntimeInstrumentationInitializer<br/>(Spring bean, at startup)"] -->|ByteBuddyAgent.install| Agent["agent"]
Agent -->|"classes annotated @Extendable<br/>in com.github.enerccio.*"| Vis["ExtendableMethodVisitor<br/>rewrites each method"]
Vis --> Code["instrumented method"]
Code -->|enter / leave| Holder["ExtensionServiceHolder"] --> ES["ExtensionServiceImpl<br/>decorators by class + method name"]RuntimeInstrumentationInitializer installs a ByteBuddy agent into the running JVM when the Spring context starts.
The agent transforms every class annotated @Extendable whose name starts with com.github.enerccio - classes
loaded later when they are loaded, classes already loaded by retransformation. Only method bodies change; the class
keeps its fields and methods (disableClassFormatChanges), which is what makes retransformation possible.
ExtendableMethodVisitor rewrites every method that is not a constructor, not static and not synthetic (lambda
bodies are synthetic). In pseudo-code, an instrumented method looks like this:
ReturnType method(A a, B b) {
ExtensionService service = ExtensionServiceHolder.getInstance();
ExtendableMethodContext context = service.createContextHolder(this);
context.registerMethodArgument("a", a, A.class); // declared parameter types
context.registerMethodArgument("b", b, B.class);
service.onExtendableMethodEnter(DeclaringClass.class, this, context, "method");
a = (A) <argument "a" from context>; // decorators may have replaced it
b = (B) <argument "b" from context>;
try {
... original body; after every store into an object local variable:
context.registerLocalVariable("name", value, value.getClass());
... before every return:
service.onExtendableMethodLeave(DeclaringClass.class, this, context, "method", null);
return result;
} catch (Throwable t) {
service.onExtendableMethodLeave(DeclaringClass.class, this, context, "method", t);
throw t;
}
}
This runs on every call, whether a decorator is registered or not. That's why @Extendable is on UI classes,
which run at human speed, and not on services or entities.
Names come from the class file: parameter names from the MethodParameters attribute (the application is compiled
with -parameters), local variable names from the local variable table (preserveAllLocals). If either is
missing, arguments are called arg0, arg1... and locals var<slot>.
Registering a decorator
ExtensionDecorator decorator = new ExtensionDecorator() {
@Override
public void onMethodEnter(Object instrumented, ExtendableMethodContext context) throws Exception {
// before the body: arguments are known, no locals yet
}
@Override
public void onMethodLeave(Object instrumented, ExtendableMethodContext context, Throwable throwing) throws Exception {
// after the body: arguments, locals, fields; throwing != null if the method is throwing
}
};
extensionService.registerDecorator(decorator,
"com.github.enerccio.marginalia.ui.dialogs.manuscript.ManuscriptStoryPart", "createSidebarButton");
// in onExtensionUnload
extensionService.unregisterDecorator(decorator); // removes it from every method it was registered for
-
instrumentedis the object whose method runs (this). -
Class name is the binary name of the class that declares the method: nested classes use
$(ManuscriptStoryPart$ChatMessageCard). A method inherited from a superclass is reported under the superclass, for all subclasses; a subclass's own methods under the subclass. The class itself must be@Extendable- the annotation is not inherited, and nested classes need their own. -
Method name only: all overloads of a method share its decorators. Check the arguments (
hasMethodArgument(name, type)) if you need to tell overloads apart. -
Several decorators on the same method run in the order they were registered, for enter and for leave.
-
A decorator registered for a class or method that doesn't exist is accepted and never called - check names carefully.
Verification refuses to load an extension that does this. -
Decorators are global: they run for every user and every session. Use the arguments and fields to decide whether to act. The current user is the session-scoped
Userbean, injected as in services (The current user) - on the UI thread it resolves to the session's user.
The method context
Each call gets its own ExtendableMethodContext (calls don't share them, recursive and concurrent calls neither).
Type checks are against the registered type: for arguments the declared parameter type, for locals the runtime
class of the value (Object when the value was null), for fields and methods the declared type. The requested type
must be the same or a supertype.
Local variables, in detail:
-
Only object locals are recorded (
ASTORE):int,booleanand other primitive locals are not. -
A local is recorded each time it's assigned, so you get its last value. Locals in different scopes that reuse the same slot keep their own names.
-
Locals of lambdas (event listeners) belong to the synthetic lambda method, which is not instrumented.
Errors
The getters and the reflective calls throw a ModCompatibilityException (a private RuntimeException subclass of
ExtensionServiceImpl) when a name doesn't exist or the type doesn't match. ExtensionServiceImpl catches everything a decorator throws, so a failing decorator
never breaks the decorated method or the other decorators:
-
in
onMethodLeave, aModCompatibilityExceptionis logged as a warning:Mod '<decorator class>' skipped on <class>.<method>: Required component variable 'sidebarBtn' was not found ...; -
anything else, and anything in
onMethodEnter, is logged as an error with the stack trace.
That makes a renamed variable a log line, not a crash - but also easy to miss. When you work on a plugin, watch the log.
What a decorator can't do
-
Change the return value or skip the method. The body always runs and its result is returned. To change what a method produces, change it afterwards: the component it built (a local variable or field), the entity it saved.
-
Decorate constructors, static methods or lambda bodies. Decorate the method the constructor calls (for example
ChatMessageCard's constructor callscreateMenuItems()), or the method that registers the listener. -
Decorate classes that aren't
@Extendable, plugin classes, or library classes. -
Read primitive locals. Read the fields or objects they end up in.
Extension points
The @Extendable classes are listed in User interface → Extension points. To find a
hook for what you want to do:
-
Find the component in the UI and the class that builds it (User interface maps screens to classes).
-
Find the method that builds or refreshes it - preferably one that runs every time the component is (re)built, so that your change is applied again.
-
Find how to reach the component: a local variable of that method (
getLocalVariable), a field (getReflectiveFieldValue), or the method's arguments. -
Decide on enter or leave: almost always
onMethodLeave, when the component exists.onMethodEnteris for reading state the method is about to overwrite, or replacing arguments - the bundled plugins save their settings inUserPart.save's enter, so that the application's ownsettingService.save(userSetting)in the body stores them.
Methods the bundled plugins use, as examples:
Inner classes reach their outer instance through the synthetic field this$0.
Verification before loading
Every name a decorator uses is unchecked at compile time, so Marginalia checks them before it starts an extension:
ExtensionService.verifyExtension(Bundle bundle, File jar) (ExtensionVerifier in instruct.verify). It doesn't run
the extension, it reads its bytecode. The classes are listed by the JAR file and read from the installed bundle
(Bundle.getEntry), and everything they ask for is compared with the real classes of the running application.
What is followed. An ASM data-flow analysis (ContextInterpreter) walks every method of every class of the
extension and tracks constants, new objects and the objects that come out of the context:
-
every
registerDecorator(decorator, className, methodName)call. The class and method name must be constants (static final Stringconstants are inlined by the compiler, so they are). The decorator is the class created withnewon the way to the call, directly, through a local variable or through a field of the extension that was assigned earlier - the usualdecorator = new ExtensionDecorator() {...}; registerDecorator(decorator, ...); -
in the decorator class - all its methods, so lambdas and private helpers of the decorator count too - every call on an
ExtendableMethodContext.
What is checked, against the application's classes loaded without being initialized, the way
The object of a reflective access is followed: instrumented (of onMethodEnter / onMethodLeave) is the decorated
class; the result of getReflectiveFieldValue / callReflectiveMethod (also through a cast and a local variable) is
an object of the field's type / the method's return type; an object from getMethodArgument / getLocalVariable is
of the requested type. So the usual way to reach the outer instance of an inner class is verified in both steps:
ManuscriptStoryPart parent = context.getReflectiveFieldValue(instrumented, "this$0", ManuscriptStoryPart.class);
context.getReflectiveFieldValue(parent, "sidebarList", VerticalLayout.class); // a field of ManuscriptStoryPart
Only the decorators themselves are scanned
Nothing is followed into another class. If a decorator passes its context (or instrumented, or an object taken out of
the context) to a method of another class, or builds names at run time, what that code asks for is not checked -
checking it is up to the developer of the extension. Keep the context calls in the decorator class if you want them
verified.
The report. The result is written next to the JAR, named after it: chaptermarker.jar gets
chaptermarker.valid or chaptermarker.invalid (the one of the opposite result is deleted). It is plain text: the
extension, the bundle, the time, Result: VALID / INVALID, the errors, the warnings and the decorators that were
checked:
Extension: chaptermarker.jar
Bundle: chaptermarker 1.0.0
Verified: 2026-10-10T18:18:31.781673Z
Result: INVALID
Errors (1):
- ChapterMarkingExtension$1.onMethodLeave line 55 (ManuscriptStoryPart.createSidebarButton): com...ManuscriptStoryPart has no field 'sidebarLst'
Checked decorators (2):
- ManuscriptStoryPart.createSidebarButton <- ChapterMarkingExtension$1
- ManuscriptStoryPart$ChatMessageCard.autosaveAndSwapToMarkdown <- ChapterMarkingExtension$2
When it runs. OsgiServiceImpl verifies a bundle before starting it, at startup and when it is uploaded:
-
A report is reused only while it is newer than the JAR and newer than the application (the later of the creation and modification time of the WAR; of the folder it is unpacked to or of the classes when it runs unpacked or from the IDE). An older report is deleted and the extension is verified again: a new version of Marginalia can break extensions that were fine, and this catches them all at the first start. Both reports at once are treated as none.
-
.valid: the extension is started..invalid: it is not - at startup the bundle stays installed, not started (stateINSTALLEDorRESOLVED), the report is logged as an error and Admin → Extensions shows anINVALIDbutton that opens it. An upload that is invalid is rejected like one that fails to start: its JAR is deleted, the replaced version is put back and the report is shown to the administrator. A verification that can't be done (an unreadable JAR) fails the extension too. -
Unloading an extension deletes its reports with the JAR.
Verification can't know about everything: a warning is not a failure, and an extension with no errors can still break at run time (for example through a context handed to another class). Watch the log of the decorated methods as before.
Keeping hooks working
Everything a decorator uses is a name: the class, the method, the argument, the local, the field. None of it is checked at compile time. To keep the damage small:
-
Prefer arguments and fields over locals, and locals over walking the component tree.
-
Use
hasLocalVariable/hasMethodArgumentand fall back gracefully, as Chapter Marker does whensidebarBtnisn't found (it takes the last button ofsidebarList). -
Keep all names in constants in one place, so a rename in the application is a one-line change in the plugin (the compiler inlines
static final Stringconstants, soverification still sees them). -
Use the application's public methods where they exist (
LorebookView.getCurrentLorebook()) instead of reading the field.
When you change the application, the other side applies: renaming or restructuring an @Extendable method can break
plugins. Check marginalia/plugins/ (a search for the method or variable name is usually enough) and mention the
change in the release notes.