Skip to content

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

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 cbindgenportal_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

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 typedefs
typedef 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 plugin
handle = 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 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

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

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

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

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; // 853
constexpr 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

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

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

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