Window Manager Daemon: Wayland Toplevel Tracking and IPC Protocol
The portal-wm daemon is a pure-Rust process that bridges two distinct communication domains: the Wayland compositor protocol for observing application windows, and a length-prefixed JSON Unix-domain-socket IPC for instructing the spatial rendering plugin where to place them. It runs as a systemd service alongside the Wayfire compositor, continuously tracking every toplevel window that appears on the desktop and making automated zone-placement decisions without user intervention. The daemon enforces a strict #![forbid(unsafe_code)] boundary — all unsafe operations (such as SO_PEERCRED verification) are delegated to the portal-common crate, keeping the window manager’s own surface area provably memory-safe.
Sources: portal/wm/src/main.rs#L1-L32, portal/wm/src/lib.rs#L1-L20
Architectural Position
Section titled “Architectural Position”portal-wm occupies a narrow but critical role in the spatial computing stack: it is the sole decision-maker for automatic window-to-zone placement. It sits between the Wayfire compositor (which provides window lifecycle events via the ext_foreign_toplevel_list_v1 Wayland extension) and the portal-spatial Wayfire plugin (which performs the actual geometric transformation of surfaces into the spatial canvas). The daemon does not modify window geometry directly — it only emits zone assignment directives through IPC, and the plugin applies them.
graph LR
WF["Wayfire Compositor<br/>(ext_foreign_toplevel_list_v1)"] -->|Wayland events| WM
WM["portal-wm daemon<br/>(zone policy + toplevel state)"]
WM -->|length-prefixed JSON IPC<br/>/run/portal/spatial.sock| SP["portal-spatial plugin<br/>(surface transform)"]
WF -->|loads| SP
SP -->|renders to| ZS["Spatial Zones<br/>Center / Left / Right"]
The daemon forms a unidirectional data pipeline: Wayland events flow in, IPC commands flow out. There is no backchannel — the portal-spatial plugin does not send unsolicited zone-change notifications to portal-wm. This design simplifies the daemon’s state model to a pure function of the compositor’s current window set.
Sources: portal/wm/src/main.rs#L168-L246, portal/wm/README.md#L9-L23
Wayland Toplevel Lifecycle Tracking
Section titled “Wayland Toplevel Lifecycle Tracking”Protocol Binding
Section titled “Protocol Binding”The daemon connects to the Wayland display via Connection::connect_to_env(), which reads WAYLAND_DISPLAY and XDG_RUNTIME_DIR from the environment — both set by the systemd unit. During the initial registry roundtrip, it searches for the ext_foreign_toplevel_list_v1 global interface and binds it at version 1. This is the Wayland extension that provides a read-only list of all toplevel surfaces managed by the compositor, including their titles, app IDs, and stable identifiers.
A critical operational detail: Wayfire 0.11 creates a stub ext_foreign_toplevel_list_v1 global in core.cpp that never emits handle events, while the ext-toplevel plugin provides the functional implementation. If both globals exist, portal-wm may bind the non-functional one. A vendored patch disables the stub global creation in Wayfire’s core, ensuring the plugin remains the sole provider.
Sources: portal/wm/src/main.rs#L65-L90, portal/systemd/portal-wm.service#L1-L22, patches/wayfire/0001-disable-duplicate-foreign-toplevel-global.patch#L1-L32
Event Dispatch Model
Section titled “Event Dispatch Model”The daemon implements three Wayland Dispatch trait implementations, each handling a distinct protocol object:
| Dispatch Target | Events Handled | Purpose |
|---|---|---|
WlRegistry |
Global |
Detect and bind the foreign toplevel list interface |
ExtForeignToplevelListV1 |
Toplevel, Finished |
Window appearance/withdrawal lifecycle |
ExtForeignToplevelHandleV1 |
Title, AppId, Identifier, Closed, Done |
Per-window metadata updates |
The event_created_child! macro on the list dispatch implementation instructs the wayland-client event loop to automatically create ExtForeignToplevelHandleV1 child objects when the list emits Toplevel events — this is how new windows enter the tracking set.
Sources: portal/wm/src/main.rs#L92-L166
Toplevel State Machine
Section titled “Toplevel State Machine”Each tracked window is represented by a ToplevelInfo struct stored in a HashMap keyed by the Wayland handle proxy. The daemon applies a state machine driven by Wayland events:
stateDiagram-v2
[*] --> Announced: ListEvent::Toplevel
Announced --> Metadata: HandleEvent::Title / AppId / Identifier
Metadata --> Assigned: HandleEvent::Done<br/>(title or app_id non-empty)
Assigned --> IPC_Sent: main loop iteration<br/>(assign_zone succeeds)
IPC_Sent --> Closed: HandleEvent::Closed
Assigned --> Closed: HandleEvent::Closed
Closed --> [*]
The Done event is the assignment trigger. The Wayland protocol sends Done after a burst of metadata events (title, app_id, identifier) to signal that all properties for this roundtrip are stable. The daemon waits for Done and then checks whether (a) the window has not yet been assigned a zone and (b) at least one identifying field (title or app_id) is non-empty. This prevents premature assignments for windows that have not yet been mapped or lack metadata.
On Closed, the daemon releases the occupied zone slot back to the policy engine and removes the toplevel from the tracking map.
Sources: portal/wm/src/main.rs#L34-L41, portal/wm/src/main.rs#L119-L166
Zone Assignment Policy Engine
Section titled “Zone Assignment Policy Engine”The ZonePolicy struct is the decision-making core of the daemon. It maintains a per-zone occupancy count ([usize; 4] indexed by ZoneId discriminant) and a set of known utility application identifiers. The policy operates on a simple two-tier classification: utility apps (terminals, file managers) are steered toward peripheral zones, while everything else receives the center zone first.
Zone Classification
Section titled “Zone Classification”| Zone | ZoneId |
Display Priority | Default Scale | Spatial Role |
|---|---|---|---|---|
| Center | 0 | 100 | 1.0 | Primary focal area |
| Left Peripheral | 1 | 50 | 0.8 | Left-side secondary content |
| Right Peripheral | 2 | 50 | 0.8 | Right-side secondary content |
| Overlay | 3 | 200 | 1.0 | UI elements (never auto-assigned) |
The canvas is physically partitioned into three equal thirds across a 2560-pixel-wide output: left peripheral occupies pixels 0–853, center 853–1706, and right peripheral 1706–2560. The Overlay zone is reserved by the policy engine — assign() never returns ZoneId::Overlay, ensuring that UI elements like the keyboard surface and launcher are never displaced by application windows.
Sources: portal/wm/src/zone_policy.rs#L1-L96, portal/spatial/src/zone.rs#L19-L99, portal/compositor/spatial-warp.toml#L1-L32
Assignment Fill Order
Section titled “Assignment Fill Order”The policy engine uses occupancy counts to determine placement, with different preference orders depending on the app classification:
Non-utility apps (browsers, editors, games): → Center → Left Peripheral → Right Peripheral → Center (stacking)
Utility apps (foot, alacritty, kitty, nautilus, thunar, etc.): → Left Peripheral → Right Peripheral → Center (stacking)
When all three primary zones are occupied, additional windows stack into the Center zone. The release() method decrements the zone count when a window closes, making the slot available for future assignments. Utility app matching uses case-sensitive contains() substring detection on the app_id field, which means org.gnome.nautilus matches but org.gnome.Nautilus does not.
Sources: portal/wm/src/zone_policy.rs#L44-L89
Utility App Registry
Section titled “Utility App Registry”The default utility application set covers common lightweight Linux desktop applications:
foot, alacritty, kitty, wezterm, weston-terminal,nautilus, thunar, nemo, dolphin, ranger, yazi,mousepad, gedit, xtermThe add_utility_app() method exists for dynamic registration but is currently reserved for future use. The list is stored as a HashSet<String> for O(1) membership testing.
Sources: portal/wm/src/zone_policy.rs#L8-L23
IPC Protocol: Length-Prefixed JSON over Unix Socket
Section titled “IPC Protocol: Length-Prefixed JSON over Unix Socket”Framing Format
Section titled “Framing Format”Communication with the portal-spatial plugin uses a length-prefixed JSON framing scheme over a single persistent Unix domain socket connection. Every message — both request and response — consists of:
- 4-byte little-endian
u32— the byte length of the JSON body - JSON body —
serde_json-serialized enum with asnake_casetype tag
The socket path is hardcoded as /run/portal/spatial.sock and is created by the portal-spatial Wayfire plugin (the server side). The daemon acts as the client, connecting once at startup and maintaining the connection for the daemon’s lifetime.
Sources: portal/spatial/src/ipc/plugin_protocol.rs#L1-L61, portal/spatial/src/ipc/mod.rs#L30-L35
Message Types
Section titled “Message Types”| Direction | Variant | Wire Format (type tag) |
Payload Fields |
|---|---|---|---|
| wm → plugin | AssignZone |
assign_zone |
surface_id: u32, zone: ZoneId |
| wm → plugin | SwitchDimension |
switch_dimension |
surface_id: u32, dimension: u8 |
| wm → plugin | GetAssignments |
get_assignments |
(none) |
| plugin → wm | Ok |
ok |
(none) |
| plugin → wm | Error |
error |
message: String |
| plugin → wm | Assignments |
assignments |
assignments: Vec<(u32, ZoneId)> |
The ZoneId enum serializes as its variant name string ("Center", "LeftPeripheral", etc.) via default serde enum serialization — not as the numeric discriminant. The surface_id field carries the Wayland handle’s protocol ID (handle.id().protocol_id()), which the plugin uses to look up the corresponding Wayfire view.
Sources: portal/spatial/src/ipc/plugin_protocol.rs#L11-L25, portal/wm/src/main.rs#L217-L244
Request-Response Cycle
Section titled “Request-Response Cycle”The daemon’s main event loop performs a synchronous dispatch of all pending Wayland events, then iterates over its toplevel map to find windows with assigned zones that have not yet been communicated via IPC. For each pending assignment, it calls SpatialIpc::assign_zone(), which performs a blocking request-response round trip on the Unix socket:
sequenceDiagram
participant WM as portal-wm (main loop)
participant IPC as SpatialIpc
participant Plugin as portal-spatial plugin
WM->>WM: blocking_dispatch (Wayland events)
WM->>WM: Scan toplevels for pending assignments
loop For each unsent assignment
WM->>IPC: assign_zone(surface_id, zone)
IPC->>Plugin: [u32 LE len][JSON: assign_zone{...}]
Plugin->>IPC: [u32 LE len][JSON: ok]
IPC-->>WM: Ok(IpcResponse::Ok)
end
WM->>WM: Mark ipc_sent = true for all sent
The ipc_sent flag on each ToplevelInfo prevents redundant IPC messages across loop iterations. Once a zone assignment is acknowledged by the plugin, it is never re-sent — even if the window’s title or app_id changes subsequently.
Sources: portal/wm/src/main.rs#L210-L245, portal/wm/src/spatial_ipc.rs#L43-L124
Security Hardening
Section titled “Security Hardening”SO_PEERCRED Verification
Section titled “SO_PEERCRED Verification”Before sending any IPC bytes, the daemon verifies the identity of the server process via the Linux SO_PEERCRED socket option. This kernel-provided credential check returns the UID, GID, and PID of the process that created the listening socket. The daemon resolves the expected UID by calling getpwnam("portal") and rejects the connection if the server’s UID does not match. This defends against a malicious process squatting on the socket path before the legitimate portal-spatial plugin starts.
On non-Linux hosts (such as the macOS development surface), SO_PEERCRED is unavailable, and the verification fails closed with WmError::PeerCredRejected. This is intentional — the daemon only runs in production on the Linux-based Spaceboard hardware.
Sources: portal/wm/src/spatial_ipc.rs#L29-L41, portal/common/src/ipc.rs#L62-L186
Response Size Cap
Section titled “Response Size Cap”The daemon enforces a client-side maximum payload size of 64 KiB (MAX_PAYLOAD_SIZE). When reading the response length prefix from the socket, any value exceeding this cap produces a WmError::ResponseTooLarge error before any allocation occurs. This prevents a compromised or buggy portal-spatial daemon from tricking portal-wm into an unbounded buffer allocation via a hostile length prefix, which could enable memory-exhaustion denial of service.
Sources: portal/wm/src/spatial_ipc.rs#L103-L114, portal/spatial/src/ipc/mod.rs#L33-L35
Error Taxonomy
Section titled “Error Taxonomy”The WmError enum is a #[non_exhaustive], Clone-deriving typed error covering every failure mode the daemon’s public library API can produce. Clone-ability is achieved by storing upstream error Display strings rather than the original error objects (which may not be Clone). The enum is organized into five categories:
| Category | Variants | Triggered By |
|---|---|---|
| IPC errors | IpcConnect, PeerCredRejected, IpcIo, ResponseTooLarge, IpcDecode, UnexpectedResponse |
Socket I/O, credential checks, framing |
| Config errors | ConfigRead, ConfigParse |
TOML loading |
| Toplevel management | ToplevelNotFound, ToplevelOperation |
Future library APIs (reserved) |
| Wayland connection | WaylandConnect, WaylandRoundtrip |
Future library APIs (reserved) |
| Catch-all | Other |
Ad-hoc error promotion |
The binary entry point (main.rs) uses anyhow::Result for orchestration, while the library crate (lib.rs) exposes only the typed WmError to callers. Reserved variants (toplevel and Wayland errors) are already in the public surface so future API additions do not require breaking changes.
Sources: portal/wm/src/error.rs#L22-L109
Daemon Lifecycle and Systemd Integration
Section titled “Daemon Lifecycle and Systemd Integration”Process Startup
Section titled “Process Startup”The daemon’s main() function follows a strict initialization sequence before entering the event loop:
- Tracing initialization —
tracing_subscriber::fmtwith anEnvFilterthat defaults toportal_wm=debugand overlays anyRUST_LOGdirective from the environment - Signal handler installation —
SIGTERMandSIGINThandlers viatokio::signal::unix - Daemon task spawn —
run_daemon()in a separate tokio task - Graceful shutdown —
tokio::select!on SIGTERM, SIGINT, or daemon task completion
The systemd unit portal-wm.service runs as the portal user with XDG_RUNTIME_DIR=/run/portal and WAYLAND_DISPLAY=wayland-1. An ExecStartPre script polls for the Wayland socket’s existence up to 30 times at 0.5-second intervals, ensuring the daemon does not start before the compositor is ready. If run_daemon() fails to connect to Wayland, main() catches the error via the tokio::select! daemon-task arm, logs it, and exits with code 0 — a deliberate design choice that allows systemd’s Restart=always policy to retry cleanly.
Sources: portal/wm/src/main.rs#L248-L282, portal/systemd/portal-wm.service#L1-L22
Configuration and Zone Geometry
Section titled “Configuration and Zone Geometry”The daemon loads spatial configuration from /etc/portal/spatial-warp.toml, falling back to built-in defaults if the file is missing (logged as a warning, not an error). The default configuration describes a 2560×1080 canvas divided into three equal-width zones:
| Zone | Pixel Range (x-axis) | Width |
|---|---|---|
| Left Peripheral | 0 – 853 | 853 px |
| Center | 853 – 1706 | 853 px |
| Right Peripheral | 1706 – 2560 | 854 px |
Each zone entry includes geometry bounds (left, right, bottom, top) and rotation fields (angle_deg, rotation_deg) that are deserialized but currently reserved for future per-zone rotation support. The config types use #[allow(dead_code)] annotations to suppress warnings for these forward-compatible fields.
Sources: portal/wm/src/config.rs#L60-L123, portal/compositor/spatial-warp.toml#L1-L32
Testing Strategy
Section titled “Testing Strategy”The daemon’s test suite operates at three levels, all within the crate boundary — there are no integration tests that require a live Wayland compositor:
Unit tests (zone_policy.rs, error.rs) verify the policy engine’s assignment logic, utility app detection, release semantics, and error display strings. Property-based tests (proptests.rs) use proptest to verify that ZonePolicy::assign() always returns a valid ZoneId variant for any (title, app_id) input pair, and that assignments are deterministic.
Wire-format tests (tests/spatial_ipc.rs) pin the IPC protocol contract: encoding produces the expected length-prefixed JSON with snake_case type tags, ZoneId serializes as variant-name strings, and malformed frames (truncated, oversized, invalid JSON) are rejected with specific error messages. End-to-end socket-pair tests exercise the full request-response cycle over real tokio::net::UnixStream pairs.
Binary lifecycle tests (tests/main_init.rs) spawn the actual portal-wm binary as a subprocess, verify clean exit when no Wayland compositor is available, and confirm that startup/shutdown log messages appear on stdout in the correct order.
Sources: portal/wm/src/proptests.rs#L1-L47, portal/wm/tests/integration.rs#L1-L141, portal/wm/tests/spatial_ipc.rs#L48-L330, portal/wm/tests/main_init.rs#L77-L135
Related Pages
Section titled “Related Pages”- Spatial Domain Model: Zones, Dimensions, and Assignment Policies — Deep dive into the
ZoneId,Zone, andZoneRegistrytypes that portal-wm depends on - Dual-Display Wayland Architecture: eDP-1 and Virtual GLASSES-1 Output — How the Wayfire compositor exposes outputs and how the foreign toplevel protocol fits into the display topology
- Wayfire Plugin Integration: Rust FFI via C++ Shims — The portal-spatial plugin that receives IPC assignments from portal-wm
- Systemd Service Management and Network Topology — How portal-wm.service integrates with the broader service dependency graph