Skip to content

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


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


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

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

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


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

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

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, xterm

The 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”

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:

  1. 4-byte little-endian u32 — the byte length of the JSON body
  2. JSON bodyserde_json-serialized enum with a snake_case type 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

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

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


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

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

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


The daemon’s main() function follows a strict initialization sequence before entering the event loop:

  1. Tracing initializationtracing_subscriber::fmt with an EnvFilter that defaults to portal_wm=debug and overlays any RUST_LOG directive from the environment
  2. Signal handler installationSIGTERM and SIGINT handlers via tokio::signal::unix
  3. Daemon task spawnrun_daemon() in a separate tokio task
  4. Graceful shutdowntokio::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

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


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