Wayfire Plugin Integration: Rust FFI via C++ Shims
The Portal OS platform bridges its Rust spatial computing core into the Wayfire compositor through a carefully layered FFI architecture. Three C++ Wayfire plugins, portal-spatial, portal-spatial-warp, and portal-virtual-output, use dlopen/dlsym to discover C ABI symbols exported by two Rust cdylib crates. This page documents the binding contract, the safety mechanisms that prevent undefined behavior at the Rust-C++ boundary, and the lifecycle of a view from Wayland surface creation through zone assignment and perspective warp.
Architectural Overview: Three Layers of Indirection
Section titled “Architectural Overview: Three Layers of Indirection”The integration deliberately separates concerns across three distinct layers. The Rust core (portal-spatial) implements the spatial domain model, zones, dimensions, homography, IPC protocol, and exposes it via a C ABI header auto-generated by cbindgen. The Rust plugin shim (portal-spatial-plugin) wraps the core library with Wayfire-specific state management: a global plugin state guarded by Mutex<Option<PluginState>>, a zone registry, and a Unix socket IPC server. Finally, the C++ Wayfire plugins are thin .so modules that Wayfire loads directly; at init() time, each plugin dlopens the Rust shared object and resolves its FFI entry points via dlsym.
graph TB
subgraph "Wayfire Compositor Process"
subgraph "C++ Plugins (CMake build → .so)"
PS["portal-spatial<br/>plugin.cpp"]
PSW["portal-spatial-warp<br/>plugin.cpp"]
PVO["portal-virtual-output<br/>plugin.cpp"]
end
subgraph "Rust cdylib (Cargo build → .so)"
PSP["portal-spatial-plugin<br/>lib.rs / ffi.rs"]
PSC["portal-spatial<br/>zone.rs / ipc.rs / ffi/"]
end
end
EXT["External: portal-wm daemon"]
SOCK["Unix Socket<br/>/run/portal/spatial.sock"]
PS -->|"dlopen + dlsym"| PSP
PSW -->|"dlopen + dlsym"| PSP
PS -->|"portal_spatial_view_mapped"| PSP
PSW -->|"portal_spatial_zone_for_view"| PSP
PSP -->|"depends on"| PSC
PVO -->|"pure C++ / wlroots"| WLROOTS["wlr_headless_backend"]
PSP -.->|"IPC server thread"| SOCK
EXT -.->|"IpcRequest JSON"| SOCK
This indirection exists because Wayfire’s plugin API is C++-only, plugins must inherit from wf::plugin_interface_t and connect to typed signal slots. Rust cannot implement that interface directly, so the C++ layer acts as a translation shim that converts Wayfire’s rich C++ object model into flat C ABI calls into Rust.
The Two Rust Crates: Core vs. Plugin
Section titled “The Two Rust Crates: Core vs. Plugin”Two Rust crates produce shared objects consumed by the C++ layer, each with a distinct FFI surface:
| Property | portal-spatial (Core) |
portal-spatial-plugin (Plugin) |
|---|---|---|
| Crate type | ["cdylib", "rlib"] |
["cdylib", "rlib"] |
| Output artifact | libportal_spatial.so |
libportal_spatial_plugin.so |
| FFI surface | Structured: spatial_zone_registry_*, spatial_homography_*, spatial_assignment_engine_* |
Wayfire-facing: portal_spatial_init, portal_spatial_view_mapped, portal_spatial_zone_for_view |
| Header generation | cbindgen → portal_spatial_ffi.h |
None (symbols discovered via dlsym) |
| Allocation strategy | Tracked<T> headers with magic tags |
Mutex<Option<PluginState>> global |
| Consumed by | portal-spatial-plugin crate (Rust-to-Rust) |
C++ Wayfire plugins (C-to-Rust via dlopen) |
The core crate’s FFI is designed for long-lived, opaque-pointer-style C consumers: you *_create() a handle, call accessors, then *_destroy() it. The plugin crate’s FFI is designed for the Wayfire lifecycle: portal_spatial_init() at plugin load, event callbacks during operation, portal_spatial_fini() at unload.
C++ Shim: The dlopen Contract
Section titled “C++ Shim: The dlopen Contract”Both portal-spatial and portal-spatial-warp plugins use runtime dynamic linking rather than compile-time linking. At init(), each plugin opens /usr/local/lib/libportal_spatial_plugin.so via dlopen(..., RTLD_NOW | RTLD_GLOBAL), then resolves individual function pointers via dlsym. This design choice means the C++ plugins have no compile-time dependency on the Rust crate, the build system only needs -ldl, and a Rust toolchain upgrade that changes ABI-incompatible internals does not require recompiling the C++ shims.
// portal-spatial/plugin.cpp, function pointer typedefstypedef int32_t (*portal_spatial_init_fn)();typedef void (*portal_spatial_fini_fn)();typedef int32_t (*portal_spatial_view_mapped_fn)(uint64_t surface_id, const char* app_id);typedef void (*portal_spatial_view_unmapped_fn)(uint64_t surface_id);
// init() resolves all four symbols; failure aborts the pluginhandle = dlopen(so_path, RTLD_NOW | RTLD_GLOBAL);init_fn = (portal_spatial_init_fn)dlsym(handle, "portal_spatial_init");view_mapped_fn = (portal_spatial_view_mapped_fn)dlsym(handle, "portal_spatial_view_mapped");// ...Before dlopen, the shim verifies a SHA-256 sidecar file (libportal_spatial_plugin.so.sha256) if present. This provides a tamper-evidence check: if the shared object has been modified without updating the sidecar, the plugin refuses to load. The check is optional, if no sidecar exists, loading proceeds normally.
The FFI Symbol Catalog
Section titled “The FFI Symbol Catalog”The portal-spatial-plugin crate exports five #[no_mangle] extern "C" functions that constitute the Wayfire integration contract:
| Symbol | Signature | Purpose |
|---|---|---|
portal_spatial_init |
() -> i32 |
Initialize plugin state, spawn IPC server thread; returns 0 on success |
portal_spatial_fini |
() -> () |
Signal shutdown, drain IPC threads, join accept loop |
portal_spatial_view_mapped |
(u64 surface_id, *const c_char app_id) -> i32 |
Assign a newly mapped view to a zone based on app_id heuristics; returns zone ID |
portal_spatial_view_unmapped |
(u64 surface_id) -> () |
Remove a view from the zone registry |
portal_spatial_zone_for_view |
(u64 surface_id) -> i32 |
Query zone assignment; returns -1 if not found |
portal_spatial_get_zone_geometry |
(i32 zone_id, u32 screen_w, u32 screen_h, *mut ZoneRect, *mut ZoneMatrix4) -> i32 |
Compute zone bounds and identity matrix for a zone ID |
The portal-spatial core crate exports a separate, larger FFI surface (zone registry, dimension registry, assignment engine, homography solver, IPC client) auto-documented via cbindgen into portal_spatial_ffi.h. These symbols are not directly invoked by the C++ shims, they are consumed by the portal-spatial-plugin Rust crate itself.
Panic Safety: The FFI Boundary Guard
Section titled “Panic Safety: The FFI Boundary Guard”Rust panics must never unwind across the C ABI boundary, doing so is undefined behavior. Both crates enforce this invariant with a ffi_boundary_guard wrapper that catches any panic via std::panic::catch_unwind, logs the payload through tracing, and returns a fallback value:
fn ffi_boundary_guard<R>(default: R, body: impl FnOnce() -> R) -> R { match catch_unwind(AssertUnwindSafe(body)) { Ok(v) => v, Err(payload) => { tracing::error!( target: "portal_spatial_plugin_ffi", payload = msg, "panic at FFI boundary; returning fallback" ); default } }}Every #[no_mangle] extern "C" function in both crates is wrapped in this guard. The default value is chosen per-function: -1 for init failure, 0 (center zone) for view_mapped, () for fini. This means a poisoned mutex or an indexing panic inside the Rust zone policy engine degrades gracefully, the view is assigned to center rather than crashing the compositor.
Allocation Tracking: Magic Tags and Live Flags
Section titled “Allocation Tracking: Magic Tags and Live Flags”The portal-spatial core crate implements an additional safety layer for its opaque-pointer FFI surface. Every *_create function wraps the returned value in a Tracked<T> header carrying a per-type 32-bit magic tag and an AtomicBool live flag:
| Rust Type | Magic Tag | C Type Alias |
|---|---|---|
ZoneRegistry |
ZREG |
SpatialZoneRegistry |
Zone |
ZONE |
SpatialZone |
DimensionRegistry |
DREG |
SpatialDimensionRegistry |
ActiveDimension |
ADIM |
SpatialActiveDimension |
AssignmentEngine |
AENG |
SpatialAssignmentEngine |
HomographySolver |
HSOL |
SpatialHomographySolver |
IpcClient |
IPCC |
SpatialIpcClient |
Each accessor function calls Tracked::<T>::as_ref(ptr), which checks three conditions before dereferencing: the pointer is non-null, the magic tag matches T::MAGIC, and the live flag is true. The retire function atomically swaps the live flag from true to false using AcqRel ordering, then runs drop_in_place on the data, a second retire call is a no-op rather than a double-free. This means passing a SpatialZone pointer to spatial_zone_registry_destroy is silently rejected instead of causing type confusion or memory corruption.
Plugin Lifecycle: Init, Event Loop, Fini
Section titled “Plugin Lifecycle: Init, Event Loop, Fini”The portal-spatial C++ plugin (PortalSpatialPlugin) implements the full Wayfire plugin lifecycle. The diagram below traces a view from creation through zone assignment, geometry clamping, and eventual destruction:
sequenceDiagram
participant WF as Wayfire Core
participant CPP as portal-spatial<br/>(C++ shim)
participant RUST as libportal_spatial_plugin.so<br/>(Rust FFI)
participant IPC as IPC Server Thread<br/>(/run/portal/spatial.sock)
Note over CPP,RUST: Plugin Init
WF->>CPP: init()
CPP->>CPP: dlopen(Rust .so)
CPP->>RUST: portal_spatial_init()
RUST->>RUST: Create ZoneRegistry
RUST->>IPC: Spawn accept loop thread
RUST-->>CPP: 0 (success)
CPP->>WF: Connect view_mapped / view_unmapped signals
Note over CPP,RUST: View Mapped
WF->>CPP: view_mapped_signal
CPP->>CPP: toplevel->get_app_id()
CPP->>RUST: portal_spatial_view_mapped(surface_id, "foot")
RUST->>RUST: ZonePolicy::assign("foot") → LeftPeripheral
RUST->>RUST: registry.assign(surface_id, LeftPeripheral)
RUST-->>CPP: 1 (LeftPeripheral)
CPP->>CPP: Clamp to zone bounds, center within zone
CPP->>CPP: toplevel->set_geometry(target)
Note over WF,IPC: External zone override
participant WM as portal-wm
WM->>IPC: IpcRequest::AssignZone{surface_id, zone}
IPC->>RUST: handle_request → registry.assign()
Note over CPP,RUST: View Unmapped
WF->>CPP: view_unmapped_signal
CPP->>RUST: portal_spatial_view_unmapped(surface_id)
RUST->>RUST: registry.remove(surface_id)
Note over CPP,RUST: Plugin Fini
WF->>CPP: fini()
CPP->>RUST: portal_spatial_fini()
RUST->>RUST: shutdown.store(true)
RUST->>IPC: join_clients(500ms timeout)
RUST->>RUST: join IPC thread (2s timeout)
CPP->>CPP: dlclose(handle)
The portal_spatial_fini function implements a multi-phase shutdown: first it atomically flips INITIALIZED from true to false via compare_exchange (preventing concurrent teardown), then it signals the shutdown flag to all client-handling threads, drains them with a 500ms per-thread timeout, and finally joins the IPC accept-loop thread with a 2-second timeout. Threads that fail to exit within the timeout are leaked with a tracing::warn, the compositor stays alive even if a client handler is stuck.
Zone Policy: App-ID Classification Engine
Section titled “Zone Policy: App-ID Classification Engine”When a view is mapped, the Rust zone policy engine inspects the app_id string to determine placement. The policy classifies applications into three tiers:
| Category | Examples | Target Zone | Rationale |
|---|---|---|---|
| Utility apps | foot, alacritty, kitty, nautilus, htop, pavucontrol | Left/Right Peripheral (balanced) | Secondary tools that don’t need primary focal area |
| Browsers (explicitly NOT utility) | firefox, chromium, epiphany | Center | Primary content apps, the classification checks browsers first and short-circuits to prevent them from being classified as utility |
| Overlay apps | mako, wob, rofi, wofi, fuzzel | Overlay (zone 3) | Notifications, launchers, OSD elements that float above content |
| Default | Anything unmatched | Center | Unknown apps get the primary focal area |
Peripheral balancing is load-aware: the policy receives a [usize; 3] array of current zone counts and assigns the new view to the less-populated peripheral. This prevents all utility windows from piling up on one side.
Window Geometry Clamping: The C++ Side
Section titled “Window Geometry Clamping: The C++ Side”After the Rust FFI returns a zone ID, the C++ shim computes the target window geometry. The portal-spatial-config.h header defines the shared constants that both plugins use to stay synchronized:
constexpr int SCREEN_WIDTH = 2560;constexpr int SCREEN_HEIGHT = 1080;constexpr int ZONE_WIDTH = SCREEN_WIDTH / 3; // 853constexpr int ZONE_CENTER = 0;constexpr int ZONE_LEFT_PERIPHERAL = 1;constexpr int ZONE_RIGHT_PERIPHERAL = 2;constexpr int MAX_WINDOW_HEIGHT = 900;The zone_x_offset() helper maps zone IDs to pixel offsets: center starts at ZONE_WIDTH (853px), left peripheral at 0, right peripheral at ZONE_WIDTH * 2 (1706px). The plugin clamps the window’s natural width to ZONE_WIDTH and height to MAX_WINDOW_HEIGHT, then centers the clamped rectangle within the zone horizontally and on-screen vertically. The resulting wf::geometry_t is applied via toplevel->set_geometry().
The runtime-configurable source of truth lives in portal/compositor/spatial-warp.toml, which defines per-zone geometry with sub-pixel precision. The header constants are compile-time fallbacks; if the two diverge, the TOML wins because it is read by libportal_spatial.so at runtime.
Spatial Warp Plugin: Perspective Distortion
Section titled “Spatial Warp Plugin: Perspective Distortion”The portal-spatial-warp plugin operates on the same dlopen/dlsym pattern but resolves only portal_spatial_zone_for_view, it does not call init/fini. Instead, it relies on the portal-spatial plugin having already initialized the Rust library. The warp plugin applies a trapezoidal perspective correction to windows placed in peripheral zones using Wayfire’s view_3d_transformer_t:
The warp computation is deliberately simple: a single perspective term in the projection matrix’s [2][0] element creates a horizontal keystone effect. For the left peripheral zone, the positive value contracts the right (far) edge; for the right peripheral, it is negated to contract the left (far) edge. The WARP_STRENGTH constant (0.15) controls intensity. This approach avoids full homography decomposition at render time, the Rust homography solver is available via the core crate’s FFI for more complex cases, but the warp plugin uses a direct GLM matrix for performance.
Virtual Output Plugin: Pure C++ (No Rust Dependency)
Section titled “Virtual Output Plugin: Pure C++ (No Rust Dependency)”The third plugin, portal-virtual-output, is pure C++ with no Rust dependency. It creates a headless wlroots backend and attaches a 2560×1080 virtual output named HEADLESS-1. Wayfire’s wayfire.ini renames this output to the display output and positions it at the global origin (0,0). This output is what the streaming pipeline captures via DMA-BUF export. The plugin is included here for completeness, it participates in the same Wayfire plugin loading mechanism but has no FFI surface.
IPC Layer: Zone Assignment from External Daemons
Section titled “IPC Layer: Zone Assignment from External Daemons”The portal-spatial-plugin crate runs a Unix socket server on /run/portal/spatial.sock that allows external processes, most notably the portal-wm window manager daemon, to override zone assignments at runtime. The IPC protocol uses length-prefixed JSON framing: a 4-byte little-endian u32 length header followed by a JSON payload serialized via serde_json.
The server enforces three layers of protection. First, SO_PEERCRED verification rejects any connection that does not originate from the portal user or root. Second, a per-connection token-bucket rate limiter caps throughput at 100 requests per second, excess requests receive an IpcResponse::Error but the connection stays open. Third, a 64 KiB payload limit prevents unbounded buffer allocation from hostile or buggy peers.
| IPC Request | Action |
|---|---|
AssignZone { surface_id, zone } |
Override zone assignment for a surface |
SwitchDimension { surface_id, dimension } |
Set dimension metadata for a surface |
GetAssignments |
Return all current surface→zone mappings |
Build System: CMake Meets Cargo
Section titled “Build System: CMake Meets Cargo”The C++ plugins are built via CMake, which discovers the Wayfire plugin directory through pkg-config --variable=plugindir wayfire. The Rust crates are built separately via cargo build -p portal-spatial-plugin (which transitively compiles portal-spatial). The resulting .so files are deployed to /usr/local/lib/ (Rust) and the Wayfire plugin directory (C++) respectively.
| Build Component | Build Tool | Key Dependency | Output Location |
|---|---|---|---|
| C++ plugins | CMake 3.10+ | wayfire, wlroots-0.20 (pkg-config) |
${WAYFIRE_PLUGINDIR}/libportal-spatial.so |
| Rust plugin | Cargo | portal-spatial, portal-common |
/usr/local/lib/libportal_spatial_plugin.so |
| Rust core | Cargo + cbindgen | nalgebra, serde, hmac, sha2 |
/usr/local/lib/libportal_spatial.so |
| FFI header | cbindgen (build.rs) | , | portal/spatial/portal_spatial_ffi.h (generated) |
The C++ CMakeLists links only -ldl, there is no static or dynamic link to any Rust library. The dlopen at runtime creates the only binding. This means a Rust toolchain update that changes internal ABI does not require recompiling the C++ plugins, as long as the #[no_mangle] extern "C" symbol signatures remain stable.
Error Code Contract
Section titled “Error Code Contract”The portal-spatial-plugin crate defines stable i32 error codes as part of its FFI contract. These values are documented in the c_error_code module and must never be renumbered, only appended:
| Constant | Value | Meaning |
|---|---|---|
OK |
0 |
Success |
GENERIC |
-1 |
Init failure, caught panic, lock poisoned |
INVALID_ARG |
-2 |
Null or misaligned out-pointer, out-of-range zone ID |
NOT_INITIALIZED |
-3 |
Plugin not yet initialized via portal_spatial_init |
IPC_IO |
-10 |
Socket bind, accept, read, write failure |
IPC_DECODE |
-11 |
JSON parse error or framing violation |
IPC_ENCODE |
-12 |
JSON serialization failure |
IPC_PAYLOAD_TOO_LARGE |
-13 |
Payload exceeded 64 KiB limit |
SPATIAL |
-20 |
Forwarded from upstream portal-spatial error |
All values are non-positive, reserving the positive range for domain values (zone IDs, dimension IDs) that some FFI functions return directly.
Thread Safety Model
Section titled “Thread Safety Model”The plugin operates across three thread categories, each with distinct synchronization requirements. The Wayfire main thread calls init/fini and receives view lifecycle signals, these are single-threaded by Wayfire’s contract. The IPC accept-loop thread is spawned inside portal_spatial_init and polls a non-blocking UnixListener with a 10ms sleep interval. Client handler threads are spawned per-accepted-connection and run the length-prefixed JSON request loop.
The ZoneRegistry uses parking_lot::RwLock<HashMap<...>> for read-heavy access patterns (zone lookups during warp computation far outnumber writes). The global PluginState is guarded by std::sync::Mutex<Option<PluginState>>, the Option enables clean init/fini transitions without sentinel values. An AtomicBool INITIALIZED provides a fast-path check that avoids taking the mutex on every FFI call when the plugin is uninitialized.
Related Pages
Section titled “Related Pages”- Dual-Display Wayland Architecture: the tactile surface and Virtual the display output Output, how the virtual output created by
portal-virtual-outputintegrates into the dual-display topology - Spatial Domain Model: Zones, Dimensions, and Assignment Policies, deeper coverage of the
ZoneIdenum,ZoneRegistry, and dimension switching - Spatial Warp Rendering: Homography-Based Head Tracking Correction, the Rust homography solver exposed via the core crate’s FFI
- Window Manager Daemon: Wayland Toplevel Tracking and IPC Protocol, how
portal-wmconsumes the IPC socket to override zone assignments