The Host ↔ Plugin Boundary
The boundary between framelift and the plugin DLLs/SOs it loads is the single
most important constraint in the system. It is a COM-like binary ABI: pure
abstract interfaces and POD-only method signatures, fronted by a Qt plugin factory.
A plugin built with any compatible compiler loads into the host regardless of how
the host was built — no shared C++ runtime is required.
This page describes the boundary at an architectural level; the SDK ABI Compatibility reference covers the rules a plugin author must follow.
The plugin entry point
A plugin is one Qt plugin DLL/SO. Its root object implements the IPlugin
interface and carries Q_PLUGIN_METADATA whose FILE is the CMake-generated
metadata JSON (identity + ABI). The host loads it with QPluginLoader:
class IPlugin
{
public:
virtual void SetLogSink(Log::SinkFn fn) noexcept = 0; // route plugin Log::* to the host
virtual IModule* CreateModule() noexcept = 0; // the plugin's one module
virtual void DestroyModule(IModule* module) noexcept = 0;
virtual QObject* GetViewModel(IModule* module) noexcept = 0; // module as QML view-model, or nullptr
virtual const char* QmlEntryUrl() noexcept = 0; // qrc URL of the root QML, or nullptr
virtual int RenderOrder() noexcept = 0; // z-order for a QML module
};
The host reads the embedded Q_PLUGIN_METADATA JSON first — identity and ABI —
via QPluginLoader::metaData(), without instantiating anything or touching a
vtable. Only for an ABI-compatible, dependency-satisfied plugin does it call
instance(), qobject_cast<IPlugin*> the root, and create the module. Each plugin
owns exactly one IModule; FRAMELIFT_MODULE_ENTRY generates the IPlugin
factory for you.
No STL across the edge
No STL type (std::string, std::vector, …) ever crosses the boundary. Strings
cross as const char* with buffer/length getters; collections cross via
enumeration callbacks; data crosses as C POD structs. The SDK provides author-side
helpers that wrap these patterns back into ergonomic std::string/lambda code,
but that wrapping compiles into the plugin and never crosses the edge.
Capability discovery, not version negotiation
The bootstrap object handed to each module, IModuleContext, is deliberately
tiny: it carries only a service registry and a publish/subscribe bus.
Every host capability — settings, the plugin catalogue, media playback, the
window — is a small interface a module discovers with ctx.GetService<T>() and
null-checks.
This is the key design choice: adding a capability is a new interface, never an append to an existing one. New host functionality therefore never changes the shape of anything an existing plugin already uses, so old and new plugins coexist by construction.
A single-integer ABI
Because the host and all plugins are built in lockstep from one source tree, the
ABI is a single integer, FRAMELIFT_ABI_VERSION. The loader accepts a plugin
iff plugin.abiVersion == host.abiVersion — an exact match. A mismatch means
a stale binary to rebuild, not a version to negotiate. It is bumped only on a true
break to the load-bearing handshake (the IPlugin interface or its IID, the
embedded plugin metadata shape, a host-called interface like IModule, or the
bootstrap surface of IModuleContext).
Cross-plugin communication
Plugins never link against each other. They interact only through IModuleContext:
- Pub/sub events — the default channel. POD payloads keyed by
EventId(OpenFileRequestEvent,FileOpenedEvent, …). Commands and notifications are events. - Services — for synchronous queries and host capabilities events can't express. A provider registers under an interface id; consumers discover it and null-check — never concrete plugin types.
See the SDK guide on cross-plugin communication for the author's view.