Plugin System
Waxed Plugin System
Overview
The Waxed plugin system is a modular, stateless-core architecture that allows dynamic loading of rendering plugins. The core manages plugin lifecycle, visibility, and inter-plugin communication while remaining “blind” to plugin semantics.
Architectural Principles
- Blind Core: The core stores only technical metadata about plugins, not semantic information.
- Plugin State Ownership: Each plugin owns its
PluginState*via RAII. Core holds opaque pointers. - Single Visible Plugin: Only one plugin is visible at a time (its output is displayed).
- Fallback Handler: Loads fallback plugin when a plugin fails to load or initialize.
Core Components
- PluginManager: Manages plugin lifecycle, visibility, and rendering
- PluginLoader: Handles dynamic library loading and symbol resolution
- PluginRegistry: Discovers and catalogs available plugins
- CoreAPI: Controlled interface for plugins to communicate with core (see separate CoreAPI documentation)
Plugin Discovery
Plugins are discovered from standard directories in order:
1. /usr/local/lib/waxed/plugins/
2. /usr/lib/waxed/plugins/
3. ~/.local/lib/waxed/plugins/
4. CMAKE_INSTALL_PREFIX/lib/waxed/plugins/
Plugin naming convention: lib<name>.so (e.g., libdesktop.so → plugin name: desktop)
Fallback Plugin
A special plugin designated as the fallback when a plugin fails to load or initialize. Fallback plugins:
- Handle error cases like broken plugins that cannot be loaded
- Cannot be loaded by plugins (only Core can load them)
- Cannot be unloaded if they are the only remaining plugin
Note: The fallback plugin does NOT provide crash recovery. If a plugin crashes during rendering, the entire display server terminates. The fallback only handles loading/initialization failures.
Plugin Lifecycle
The Plugin ABI
Plugins must export the following C symbols (C linkage, no name mangling):
extern "C" {
// Initialize plugin with DRM device and display configuration
auto waxed_plugin_init(
int drm_fd,
CoreAPI* core,
uint32_t display_count,
const DisplayConfig* displays
) noexcept -> PluginState*;
// Notify plugin of visibility state changes
auto waxed_plugin_visibility_changed(PluginState* state, bool visible) noexcept -> void;
// Render one frame into core-owned DMA-BUF
auto waxed_plugin_render(PluginState* state, const RenderTarget* target) noexcept -> int;
// Cleanup plugin resources
auto waxed_plugin_cleanup(PluginState* state) noexcept -> void;
}
waxed_plugin_init
Called when the plugin is loaded. Receives:
drm_fd: DRM device file descriptor for direct KMS access (if needed)core: Core API handle for controlled communication with coredisplay_count: Number of active displaysdisplays: Array of display configurations
Returns: PluginState* pointer (plugin-allocated state) or nullptr on failure
Example:
extern "C" auto waxed_plugin_init(
int drm_fd,
CoreAPI* core,
uint32_t display_count,
const DisplayConfig* displays
) noexcept -> PluginState* {
auto state = std::make_unique<MyPluginState>();
state->core = core;
// Initialize plugin resources...
return static_cast<PluginState*>(state.release());
}
waxed_plugin_visibility_changed
Called when plugin visibility changes:
visible = true: Plugin’s output will be displayed on screenvisible = false: Plugin’s output will be ignored (hidden)
Note that hidden plugins still receive render calls - they just aren’t shown.
Example:
extern "C" auto waxed_plugin_visibility_changed(PluginState* state, bool visible) noexcept -> void {
auto* plugin = static_cast<MyPluginState*>(state);
if (visible) {
plugin->resume();
} else {
plugin->suspend(); // Can pause expensive operations
}
}
waxed_plugin_render
Called every frame for all loaded plugins.
Responsibilities:
- Wait on
target->release_fence_fdbefore writing (if >= 0) - Render content into
target->dma_buf_fd(DO NOT close the FD) - Return
render_fence_fdfor completion signaling
Fence Lifecycle:
release_fence_fd: Borrowed, DO NOT close (core owns it)render_fence_fd: Plugin creates and transfers ownership to core
Example:
extern "C" auto waxed_plugin_render(PluginState* state, const RenderTarget* target) noexcept -> int {
auto* plugin = static_cast<MyPluginState*>(state);
// Wait for previous frame to complete (explicit sync)
if (target->release_fence_fd >= 0) {
sync_wait(target->release_fence_fd, -1);
}
// Render into core-owned DMA-BUF
plugin->render_to_dma_buf(target->dma_buf_fd, target->width, target->height);
// Create and return render completion fence (transfers ownership)
int render_fence = plugin->create_fence();
return render_fence; // Core takes ownership and will close
}
waxed_plugin_cleanup
Called when plugin is unloaded. Plugin must:
- Free all allocated resources
- Close file descriptors
- Release Vulkan resources (if applicable)
Example:
extern "C" auto waxed_plugin_cleanup(PluginState* state) noexcept -> void {
auto* plugin = static_cast<MyPluginState*>(state);
delete plugin; // RAII cleanup
}
PluginState Ownership Model
The plugin owns its state. Core holds an opaque pointer wrapped in RAII:
// Custom deleter for plugin state
struct PluginStateDeleter {
plugin_cleanup_fn cleanup_fn = nullptr;
void operator()(void* state) const noexcept {
if (state && cleanup_fn) {
cleanup_fn(static_cast<PluginState*>(state));
}
}
};
// Unique pointer type for owning plugin state
using PluginStatePtr = std::unique_ptr<PluginState, PluginStateDeleter>;
Ownership Rules:
- Plugin allocates
PluginStateinwaxed_plugin_init - Core stores it in
PluginStatePtrwith custom deleter - When
PluginStatePtris destroyed,waxed_plugin_cleanupis called - Plugin is responsible for freeing its own state in cleanup
Visibility Management
Only one plugin is visible at a time. The visible plugin’s rendered output is displayed on screen.
Visibility vs Hidden
Visibility Transitions
Visibility Guidelines
- On visible=true: Prepare for active display, resume animations
- On visible=false: Can pause expensive operations to save resources, but still must handle render calls
- Hidden plugins render to a framebuffer that is never presented
Render Flow
RenderTarget Structure
Core allocates and owns the DMA-BUF. Plugin receives a borrowed handle:
struct RenderTarget {
int dma_buf_fd; // Core-owned FD (Plugin MUST NOT close)
uint32_t buffer_id; // 0, 1, or 2 (for import caching)
uint32_t width;
uint32_t height;
uint32_t stride;
uint32_t format; // DRM FourCC
uint64_t modifier; // DRM format modifier
int release_fence_fd; // KMS completion fence (Plugin waits before writing)
// Cursor handoff fields
uint32_t display_id; // Unique display identifier
int32_t cursor_x; // Display-local cursor X position
int32_t cursor_y; // Display-local cursor Y position
};
Ownership Model:
- Core allocates the DMA-BUF and owns the FD
- Plugin receives a BORROWED fd (MUST NOT close it)
- Plugin renders into the buffer
- Plugin waits on release_fence_fd before writing
- Plugin returns render_fence_fd for completion signaling
Cursor Handoff
The cursor handoff mechanism allows a plugin to take control of cursor rendering for specific displays:
How it works:
- Plugin registers a cursor provider callback with the core
- When the plugin wants custom cursor rendering, it requests takeover for a display
- The RenderTarget includes
cursor_xandcursor_ypositions for the plugin to use - Plugin renders the cursor into the framebuffer at those coordinates
- Plugin can notify the core when cursor shape changes (e.g., hover state changes)
- Plugin releases takeover to restore default hardware cursor
This allows plugins to implement custom cursor appearances (different shapes, animations, effects) without the core needing to know about cursor semantics.
Fallback Plugin Behavior
When a plugin fails to load or initialize:
Important: This handles loading failures only, NOT runtime crashes. A crash during rendering will terminate the entire display server.
Display Configuration
Plugins receive display configuration during init and on changes:
struct DisplayConfig {
uint32_t display_id; // Unique identifier (0, 1, 2, ...)
DisplayRole role; // Primary or Secondary
DisplayLayoutMode layout_mode; // Mirror or Extend
uint32_t width; // Display width in pixels
uint32_t height; // Display height in pixels
uint32_t refresh_rate; // Refresh rate in Hz
int32_t position_x; // X position in extended mode
int32_t position_y; // Y position in extended mode
bool vrr_capable; // Display/mode supports VRR
bool vrr_enabled; // VRR is currently enabled
};
Display Change Notification
Plugins can register for display configuration changes:
// Register callback
core->funcs->register_display_callback(core, on_display_changed);
// Callback signature
auto on_display_changed(PluginState* state, const DisplayConfig* config) noexcept -> void {
if (!config) {
// No displays available
return;
}
// Update rendering for new configuration
}
Core API
The Core API provides controlled access to core functionality. This is a summarized overview; detailed function signatures are documented in the CoreAPI reference.
Capabilities Overview
| Category | Purpose |
|---|---|
| Plugin Management | Load/unload plugins, change visibility |
| Background Image | Set and retrieve background images |
| Vulkan Access | Register Vulkan context, get device info |
| Cursor Handoff | Take over cursor rendering per display |
| Input Events | Access mouse and keyboard event queues |
| Properties | Register runtime-configurable settings |
| Display Info | Get display configuration, receive changes |
| API Registration | Register plugin API calls for external use |
| IPC Commands | Execute IPC commands from within plugin |
Property Registration
Plugins can register properties that can be modified at runtime via IPC (waxedctl) or programmatically:
// Register a property with a setter callback
auto register_property(CoreAPI* core,
const char* namespace_, // e.g., "yaml", "config"
const char* key, // e.g., "background"
const char* description, // Human-readable description
PropertySetterFn setter, // Callback when property is set
void* user_data) -> int;
When a property is set (via waxedctl property set or internal call), the setter callback is invoked with the new value. This allows plugins to react dynamically to configuration changes without reloading.
API Registration
Plugins can register API calls that external tools (like waxedctl) can invoke:
// Register an API call
auto register_api_call(CoreAPI* core,
const char* api_name, // e.g., "surface_create"
const void* args, // Argument specification
size_t arg_count,
const char* description,
void* callback) -> int;
This tells the core that the plugin supports a specific API call. For example, a desktop plugin might register surface_create, surface_move, surface_resize APIs that allow external tools to manipulate windows. When someone calls waxedctl api surface_create 100 100 800 600, the core forwards the call to the visible plugin’s registered callback.
IPC Command
Plugins can execute IPC commands internally using the same functions available to external tools:
// Enqueue an IPC command (same commands waxedctl uses)
auto enqueue_command(CoreAPI* core, const char* command) -> int;
This allows plugins to trigger actions like loading other plugins, changing properties, etc., using the exact same IPC command syntax that waxedctl uses externally.
Minimal Plugin Example
#include <waxed/plugin.h>
#include <waxed/core_api.h>
#include <cstring>
#include <memory>
// Plugin state structure
struct MinimalState {
CoreAPI* core = nullptr;
uint32_t width = 0;
uint32_t height = 0;
bool suspended = false;
};
extern "C" {
auto waxed_plugin_init(
int drm_fd,
CoreAPI* core,
uint32_t display_count,
const DisplayConfig* displays
) noexcept -> PluginState* {
(void)drm_fd;
(void)display_count;
(void)displays;
auto state = std::make_unique<MinimalState>();
state->core = core;
if (displays && display_count > 0) {
state->width = displays[0].width;
state->height = displays[0].height;
}
return static_cast<PluginState*>(state.release());
}
auto waxed_plugin_visibility_changed(PluginState* state, bool visible) noexcept -> void {
auto* plugin = static_cast<MinimalState*>(state);
plugin->suspended = !visible;
}
auto waxed_plugin_render(PluginState* state, const RenderTarget* target) noexcept -> int {
auto* plugin = static_cast<MinimalState*>(state);
// Wait for previous frame (explicit sync)
if (target->release_fence_fd >= 0) {
sync_wait(target->release_fence_fd, -1);
}
// Render into DMA-BUF
// (In real plugin: import DMA-BUF to Vulkan, render, export fence)
// Return fence (ownership transferred to core)
return dup(target->release_fence_fd); // Simplified example
}
auto waxed_plugin_cleanup(PluginState* state) noexcept -> void {
auto* plugin = static_cast<MinimalState*>(state);
delete plugin;
}
} // extern "C"
Building a Plugin
CMakeLists.txt Example
cmake_minimum_required(VERSION 3.20)
project(myplugin CXX)
set(CMAKE_CXX_STANDARD 26)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(PkgConfig REQUIRED)
pkg_check_modules(WAXED REQUIRED waxed)
add_library(myplugin MODULE
src/plugin.cpp
)
target_include_directories(myplugin PRIVATE
${WAXED_INCLUDE_DIRS}
)
target_link_libraries(myplugin PRIVATE
${WAXED_LIBRARIES}
dl
)
set_target_properties(myplugin PROPERTIES
PREFIX ""
OUTPUT_NAME "myplugin"
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_INSTALL_LIBDIR}/waxed/plugins"
)
install(TARGETS myplugin
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/waxed/plugins
)
Plugin Loading Locations
At runtime, plugins are searched in the following directories:
/usr/local/lib/waxed/plugins//usr/lib/waxed/plugins/~/.local/lib/waxed/plugins/{CMAKE_INSTALL_PREFIX}/lib/waxed/plugins/
Plugins should be installed with the lib prefix and .so extension:
- Library file:
libmyplugin.so - Plugin name:
myplugin
Vulkan Usage
Plugins that use Vulkan should:
- Use
vulkan_raii.hppfor RAII-managed Vulkan objects (recommended soft requirement) - Register as Vulkan provider if creating resources for other plugins
- Use the shared Vulkan device when available
- Follow DMA-BUF export patterns for zero-copy rendering
Vulkan-Hpp RAII
Using vulkan_raii.hpp is recommended for automatic resource cleanup:
// Recommended approach
#include <vulkan/vulkan_raii.hpp>
vk::raii::Context context;
vk::raii::Instance instance = context.createInstance(...);
vk::raii::Device device = ...;
vk::raii::CommandBuffer cmd = ...;
// RAII automatically cleans up in destructors
While not enforced at compile time, this pattern helps prevent resource leaks and simplifies error handling.
Error Handling
Plugins use std::expected (C++26) for error propagation. Never use exceptions.
auto render() -> Result<void> {
if (failed) {
return std::unexpected(ErrorCode::RenderFailed);
}
return {};
}