Skip to main content

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.