Linux stream paths (plugin contract)
The developer contract for Linux stream paths. User-facing guidance on choosing a mode lives in Launch modes and capture paths.
Polaris models each user-facing Linux streaming option as a stream path: a stable id plus three orthogonal concerns.
| Concern | Meaning | Examples |
|---|---|---|
| Runtime | Who owns app paint | labwc, gamescope, none (host) |
| Capture | How frames are taken | wlroots, portal, kms, evdi, auto |
| Topology | Host display layout policy | leave_alone, host_virtual, desktop_takeover, swap_primary |
Config key: linux_stream_mode = <path id>. Legacy booleans (headless_mode, linux_use_cage_compositor, linux_prefer_gpu_native_capture) still map to/from primary paths.
Built-in path ids
Section titled “Built-in path ids”| Id | Runtime | Capture | Topology | Status |
|---|---|---|---|---|
headless_stream |
labwc | wlroots | leave_alone | Available (Private Stream) |
windowed_stream |
labwc | wlroots | leave_alone | Available (GPU-native preference) |
desktop_display |
none | portal | leave_alone | Available (Mirror Desktop / external gamescope) |
host_virtual_display |
none | auto | host_virtual | Available |
desktop_takeover |
none | auto | desktop_takeover | Available on a live Hyprland session with hyprctl and an EVDI or native wlroots virtual-output backend |
gamescope_stream |
gamescope | portal | leave_alone | Available when gamescope is on PATH (attach idle or spawn owned) |
family_isolated |
— | — | — | Not registered until PR #226 wires it (id constant kept for conf parse) |
headless_evdi |
— | — | — | Not registered until EVDI path wires it (id constant kept for conf parse) |
headless_dongle |
none | portal (default; kms optional) | swap_primary | Available when linux_streaming_output + linux_primary_output + auto_manage are set (privacy swap via kscreen-doctor; host ScreenCast after topology prepare) |
Source of truth: src/platform/linux/stream_path.{h,cpp} registry.
Render device (labwc runtime)
Section titled “Render device (labwc runtime)”The labwc paths (headless_stream, windowed_stream) choose the private
compositor’s wlroots render device differently, and it matters on a multi-GPU
host:
headless_streamruns wlroots on the headless backend, which owns DRM device selection outright — left alone it grabs the first render node it enumerates, not necessarily the configured GPU. The runtime therefore pinsWLR_RENDER_DRM_DEVICEtoadapter_name(the same/dev/dri/renderD*used for capture/encode) whenadapter_nameis an accessible device path; with no usableadapter_nameit pins toplatf::default_render_device(), a sysfs heuristic that prefers the discrete GPU (an NVIDIA driver — nvidia or nouveau — or a ≥1 GiB dedicated pool from amdgpu VRAM / Intel lmem, then the larger pool, then the boot display). The VAAPI encoder resolves the VAAPI-safe variant of the same default (NVIDIA-bound nodes excluded — no VA driver exists there) in place of its old literalrenderD128fallback, so the compositor and the encoder agree on the card either way (issues #354, #367). Known gap: an Intel Arc dGPU without lmem sysfs ranks as integrated — setadapter_nameon such hosts.windowed_streamruns wlroots as a nested wayland client of the host compositor and inherits its render device from the parent’s dmabuf feedback. The device is deliberately not forced there — overriding it could mismatch the parent and break buffer sharing.
Source of truth: labwc_process_environment_value in
src/platform/linux/cage_display_router.cpp.
Adding a new path (checklist)
Section titled “Adding a new path (checklist)”- Register a
stream_path::descriptor_tinstream_path::registry()with a stable id. - Runtime (if the path needs a private compositor):
- Implement
stream_runtime::stream_runtime_t(seestream_runtime_labwc.cpp). - Extend
stream_runtime::acquire()for the newruntime_kind_e.
- Implement
- Capture (if not covered by existing portal/kms/wlroots paths):
- Add grab backend + wire via
capture_kind_enegotiation in platform init — do not hard-code capture inside the path id switch inprocess.cpp.
- Add grab backend + wire via
- Topology (if rearranging host outputs):
- Implement prepare/restore hooks keyed by
topology_kind_e(swap primary / host virtual / desktop takeover), callable from session prep — not as ad-hoc booleans.
- Implement prepare/restore hooks keyed by
- Policy facade:
stream_display_policymaps path → legacy booleans for one release cycle. - UI: Audio/Video path cards read the same ids; mark
available: falseuntil the runtime works. - Stats: set
runtime_backend+stream_path_idviastream_stats::update_runtime_state(or rely on policybackend_namewhen idle). - Tests: selection ↔ legacy round-trip; unavailable apply rejects; launch contract lists only available primary paths.
Module map (keep boundaries)
Section titled “Module map (keep boundaries)”| Module | Owns |
|---|---|
stream_path |
Path ids + runtime/capture/topology vocabulary |
stream_display_policy |
resolve/apply + legacy bool bridge (one release cycle) |
stream_runtime |
Private compositor lifecycle (labwc adapter, gamescope). Only stream_runtime_labwc.cpp may include cage_display_router. |
session_media |
Only ordered media teardown + post-HTTP stop worker |
portal_session / portal_grab |
ScreenCast session + process-wide media cache (release_global_capture) |
pipewire_capture |
PW stream format/copy/dtor |
display_topology |
Dongle prepare/restore (kscreen) |
desktop_takeover |
Durable Hyprland workspace-move and DPMS recovery, including stale-session restoration |
process |
App launch + nested kill after session_media |
Stop callers must not invent a parallel order: confighttp / terminate_impl → session_media::prepare_for_stop() → optional proc::terminate.
What not to do
Section titled “What not to do”- Do not add a fourth boolean to encode a new mode.
- Do not special-case gamescope/EVDI only inside
cage_display_router— go throughstream_runtime. - Do not call
portal::release_global_capturefrom HTTP handlers (usesession_media). - Do not report
runtime_backendempty — useportal,host,gamescope,labwc, etc.
Relation to community PR #226
Section titled “Relation to community PR #226”Headless Streaming Display introduces EVDI grab, display swap, Family Mode isolation, and headless_source / headless_swap_mode. Those map cleanly onto:
- paths
headless_evdi,headless_dongle,family_isolated - topology
swap_primary+ captureevdi/kms - optional per-app override (Family Mode) on top of the labwc runtime
Integrate by filling the reserved registry slots and implementing runtime/capture/topology hooks — not by inventing parallel config trees.
Relation to gamescope
Section titled “Relation to gamescope”Shipped on this branch: gamescope_stream is available when gamescope is on PATH. stream_runtime_gamescope attaches to idle gamescope-0 (or starts polaris-gamescope-idle / spawns owned headless) and wraps app launches into that runtime. Capture stays portal/PipeWire-oriented; nested WSI remains a presentation sub-option (e.g. optional Steam Big Picture via polaris-gamescope-session), not a top-level path.
Non-NixOS helper install: scripts/install/README.md. Optional private ScreenCast bus is host/packaging-specific.
Input: gamescope’s wlserver creates no virtual-pointer or virtual-keyboard manager, so the Wayland route that serves labwc has nothing to bind and host uinput cannot reach a headless compositor. Mouse, keyboard and Unicode text go to gamescope’s own EIS server (ei_virtual_input_t, built when libei-1.0 is present), which is the same road XWayland’s XTEST support already takes. Gamepads are unaffected: Steam reads those from /dev/input itself. Touch and pen have no EIS equivalent — gamescope’s InputEmulation.cpp answers EIS_EVENT_TOUCH_* with “No touch support yet” — so under gamescope_stream they are dropped rather than forwarded to the host desktop. When libei is missing, or when the EIS socket cannot be reached because the compositor never started, every route falls back to host uinput exactly as before.
Still residual (not path-registry work): clean stop under load, idle preview without a live stream, and multimode conf helpers that must preserve browser_streaming.