Wayfire Plugin Integration: Rust FFI via C++ Shims
The Portal 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.
Sources: portal/wayfire-plugins/portal-spatial/plugin.cpp#L1-L151, portal/spatial-plugin/Cargo.toml#L1-L28
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.
Sources: portal/wayfire-plugins/CMakeLists.txt#L1-L34, portal/wayfire-plugins/portal-spatial/plugin.cpp#L44-L136, portal/spatial-plugin/src/lib.rs#L1-L35
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.
Sources: portal/spatial/Cargo.toml#L1-L41, portal/spatial-plugin/Cargo.toml#L1-L28, portal/spatial/portal_spatial_ffi.h#L1-L54
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.
Sources: portal/wayfire-plugins/portal-spatial/plugin.cpp#L14-L28, portal/wayfire-plugins/portal-spatial/plugin.cpp#L99-L136, portal/wayfire-plugins/portal-spatial-warp/plugin.cpp#L21-L35
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.
Sources: portal/spatial-plugin/src/ffi.rs#L86-L131, portal/spatial-plugin/src/ffi.rs#L207-L260, portal/spatial-plugin/src/ffi.rs#L269-L318, portal/spatial-plugin/src/ffi.rs#L351-L421
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.
Sources: portal/spatial-plugin/src/ffi.rs#L36-L53, portal/spatial/src/ffi/tracked.rs#L185-L210
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.
Sources: portal/spatial/src/ffi/tracked.rs#L32-L63, portal/spatial/src/ffi/tracked.rs#L96-L182, portal/spatial/src/ffi/mod.rs#L25-L57
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.
Sources: portal/spatial-plugin/src/ffi.rs#L87-L131, portal/spatial-plugin/src/ffi.rs#L145-L193, portal/wayfire-plugins/portal-spatial/plugin.cpp#L52-L96
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.
Sources: portal/spatial-plugin/src/zone_policy.rs#L28-L52, portal/spatial-plugin/src/zone_policy.rs#L57-L143
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.
Sources: portal/wayfire-plugins/portal-spatial-config.h#L1-L53, portal/wayfire-plugins/portal-spatial/plugin.cpp#L36-L87, portal/compositor/spatial-warp.toml#L1-L32
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.
Sources: portal/wayfire-plugins/portal-spatial-warp/plugin.cpp#L41-L83, portal/wayfire-plugins/portal-spatial-warp/plugin.cpp#L86-L116, portal/wayfire-plugins/portal-spatial-warp/plugin.cpp#L135-L165
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 GLASSES-1 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.
Sources: portal/wayfire-plugins/portal-virtual-output/plugin.cpp#L1-L61, portal/compositor/wayfire.ini#L1-L48
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 |
Sources: portal/spatial-plugin/src/ipc_server.rs#L37-L151, portal/spatial-plugin/src/ipc_server.rs#L208-L309, portal/spatial/src/ipc/plugin_protocol.rs#L1-L61
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.
Sources: portal/wayfire-plugins/portal-spatial/CMakeLists.txt#L1-L35, portal/wayfire-plugins/CMakeLists.txt#L1-L34, portal/spatial/build.rs#L14-L34, portal/spatial-plugin/Cargo.toml#L10-L11
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.
Sources: portal/spatial-plugin/src/error.rs#L43-L76, portal/spatial-plugin/src/error.rs#L89-L162
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.
Sources: portal/spatial-plugin/src/zone_registry.rs#L13-L18, portal/spatial-plugin/src/ffi.rs#L58-L74, portal/spatial-plugin/src/ipc_server.rs#L37-L52
Related Pages
Section titled “Related Pages”- Dual-Display Wayland Architecture: eDP-1 and Virtual GLASSES-1 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