From 6fdf7d1511edd3aa73e081dd75b5d5d7996b7576 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 10 Jun 2026 20:37:15 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20client-selectable=20compositor=20(proto?= =?UTF-8?q?col=20=E2=86=92=20host=20=E2=86=92=20client=20=E2=86=92=20C=20A?= =?UTF-8?q?BI=20=E2=86=92=20mgmt=20=E2=86=92=20web)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A client can now request which compositor backend the host drives its virtual output on (gamescope/KWin/Mutter/wlroots). The host honors the request if that backend is available, else falls back to auto-detect and reports the resolved choice back — wire-compatible both directions (no ABI bump). Protocol (punktfunk-core): - New CompositorPref (config.rs): Auto|Kwin|Wlroots|Mutter|Gamescope with u8/name mappings. Appended as one optional byte to Hello (client preference) and Welcome (host's resolved choice). Both decoders already tolerate trailing bytes, so old↔new interop is preserved — ABI_VERSION stays 2. Round-trip + back-compat (truncated-message) tests. - C ABI: punktfunk_connect_ex(compositor) + PUNKTFUNK_COMPOSITOR_* constants; punktfunk_connect delegates with AUTO, so the existing symbol is unchanged. NativeClient::connect / worker_main thread the preference through. Host: - vdisplay::available() enumerates usable backends via cheap, side-effect-free probes (KWin zkde global, gamescope binary+version, GNOME/Sway env), plus Compositor id/label/as_pref/from_pref/all helpers. - m3 handshake resolves the preference to a concrete backend during the handshake (pick_compositor pure + resolved logging), reports it in Welcome, and threads it into virtual_stream (replacing the unconditional detect()). - mgmt GET /v1/compositors lists every backend with availability + the auto-detected default (OpenAPI regenerated). Client: - punktfunk-client-rs --compositor NAME; logs the host's resolved choice from the Welcome ("session offer … compositor=…"). Web console: - Host page gains a Compositors card (availability + default badges) via the codegen'd useListCompositors hook; en/de strings added. Also fixes a pre-existing, env-dependent test-isolation bug: mgmt::tests::paired_clients_list_and_unpair seeded the real ~/.config/punktfunk/paired.json (AppState::new loads it), so a real GameStream-paired client leaked into body[0] on a dev box — now cleared first. Live-validated against headless KWin: --compositor kwin honored, --compositor mutter falls back to kwin (available=[kwin, gamescope]), resolved choice round-trips to the client. Tests: +6 (wire/back-compat, resolution precedence, endpoint); workspace green, clippy/fmt clean, C ABI harness PASS at abi_version=2, web typecheck + build clean. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/punktfunk-client-rs/src/main.rs | 24 ++- crates/punktfunk-core/src/abi.rs | 58 ++++++- crates/punktfunk-core/src/client.rs | 13 +- crates/punktfunk-core/src/config.rs | 72 +++++++++ crates/punktfunk-core/src/lib.rs | 2 +- crates/punktfunk-core/src/quic.rs | 99 +++++++++++- crates/punktfunk-host/src/m3.rs | 149 ++++++++++++++++-- crates/punktfunk-host/src/mgmt.rs | 74 ++++++++- crates/punktfunk-host/src/vdisplay.rs | 85 ++++++++++ .../punktfunk-host/src/vdisplay/gamescope.rs | 16 +- crates/punktfunk-host/src/vdisplay/kwin.rs | 6 + crates/punktfunk-host/src/vdisplay/mutter.rs | 9 ++ crates/punktfunk-host/src/vdisplay/wlroots.rs | 6 + docs/api/openapi.json | 63 ++++++++ include/punktfunk_core.h | 42 ++++- web/messages/de.json | 5 + web/messages/en.json | 5 + web/src/routes/host.tsx | 34 +++- 18 files changed, 740 insertions(+), 22 deletions(-) diff --git a/crates/punktfunk-client-rs/src/main.rs b/crates/punktfunk-client-rs/src/main.rs index 77efd27..f351264 100644 --- a/crates/punktfunk-client-rs/src/main.rs +++ b/crates/punktfunk-client-rs/src/main.rs @@ -17,15 +17,19 @@ //! Host→client datagrams (Opus audio, rumble) are counted and reported with the stream //! stats — decode/playback is the platform clients' job. //! +//! `--compositor NAME` requests a host compositor backend (`auto`|`kwin`|`wlroots`|`mutter`| +//! `gamescope`); the host honors it if available, else auto-detects and reports the resolved +//! choice in its Welcome (logged as `session offer … compositor=…`). +//! //! Usage: `punktfunk-client-rs [--connect HOST:PORT] [--mode WxHxFPS] [--out FILE] [--input-test] -//! [--pin HEX]` (M4 adds VAAPI decode + wgpu present on this same skeleton.) +//! [--pin HEX] [--compositor NAME]` (M4 adds VAAPI decode + wgpu present on this skeleton.) use anyhow::{anyhow, Context, Result}; use punktfunk_core::config::Role; use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::quic::{endpoint, io, Hello, Reconfigure, Reconfigured, Start, Welcome}; use punktfunk_core::transport::UdpTransport; -use punktfunk_core::{Mode, PunktfunkError, Session}; +use punktfunk_core::{CompositorPref, Mode, PunktfunkError, Session}; use std::io::Write; struct Args { @@ -40,6 +44,8 @@ struct Args { pair: Option, /// `--name LABEL` — how the host labels this client when pairing. name: String, + /// `--compositor NAME` — request a host compositor backend (auto|kwin|wlroots|mutter|gamescope). + compositor: CompositorPref, } fn parse_mode(m: &str) -> Option { @@ -115,6 +121,17 @@ fn parse_args() -> Args { } } }; + // A present-but-unrecognized --compositor must abort rather than silently auto-detect. + let compositor = match get("--compositor") { + None => CompositorPref::Auto, + Some(s) => match CompositorPref::from_name(s) { + Some(c) => c, + None => { + eprintln!("--compositor must be one of: auto, kwin, wlroots, mutter, gamescope"); + std::process::exit(2); + } + }, + }; Args { connect: get("--connect").unwrap_or("127.0.0.1:9777").to_string(), mode, @@ -124,6 +141,7 @@ fn parse_args() -> Args { remode, pair: get("--pair").map(String::from), name: get("--name").unwrap_or("punktfunk-client-rs").to_string(), + compositor, } } @@ -207,6 +225,7 @@ async fn session(args: Args) -> Result<()> { &Hello { abi_version: punktfunk_core::ABI_VERSION, mode: args.mode, + compositor: args.compositor, } .encode(), ) @@ -218,6 +237,7 @@ async fn session(args: Args) -> Result<()> { fec = ?welcome.fec, encrypt = welcome.encrypt, frames = welcome.frames, + compositor = welcome.compositor.as_str(), "session offer" ); diff --git a/crates/punktfunk-core/src/abi.rs b/crates/punktfunk-core/src/abi.rs index c61c944..1190ed4 100644 --- a/crates/punktfunk-core/src/abi.rs +++ b/crates/punktfunk-core/src/abi.rs @@ -477,8 +477,23 @@ unsafe fn opt_cstr<'a>(p: *const std::os::raw::c_char) -> std::result::Result *mut PunktfunkConnection { + unsafe { + punktfunk_connect_ex( + host, + port, + width, + height, + refresh_hz, + PUNKTFUNK_COMPOSITOR_AUTO, + pin_sha256, + observed_sha256_out, + client_cert_pem, + client_key_pem, + timeout_ms, + ) + } +} + +/// Like [`punktfunk_connect`], but requests a specific `compositor` backend on the host (one of +/// the `PUNKTFUNK_COMPOSITOR_*` values). `PUNKTFUNK_COMPOSITOR_AUTO` (or any unrecognized value) +/// lets the host decide; a concrete value is honored only if available, else the host falls back +/// to auto-detect. The resolved choice is logged host-side and returned over the protocol. +/// +/// # Safety +/// Same as [`punktfunk_connect`]. +#[cfg(feature = "quic")] +#[no_mangle] +pub unsafe extern "C" fn punktfunk_connect_ex( + host: *const std::os::raw::c_char, + port: u16, + width: u32, + height: u32, + refresh_hz: u32, + compositor: u32, + pin_sha256: *const u8, + observed_sha256_out: *mut u8, + client_cert_pem: *const std::os::raw::c_char, + client_key_pem: *const std::os::raw::c_char, + timeout_ms: u32, ) -> *mut PunktfunkConnection { let r = std::panic::catch_unwind(AssertUnwindSafe(|| { if host.is_null() { @@ -521,6 +575,7 @@ pub unsafe extern "C" fn punktfunk_connect( height, refresh_hz, }; + let pref = crate::config::CompositorPref::from_u8(compositor as u8); let pin = if pin_sha256.is_null() { None } else { @@ -539,6 +594,7 @@ pub unsafe extern "C" fn punktfunk_connect( host, port, mode, + pref, pin, identity, std::time::Duration::from_millis(timeout_ms as u64), diff --git a/crates/punktfunk-core/src/client.rs b/crates/punktfunk-core/src/client.rs index 9d5bd08..bf12ce6 100644 --- a/crates/punktfunk-core/src/client.rs +++ b/crates/punktfunk-core/src/client.rs @@ -11,7 +11,7 @@ //! invariant) plus a blocking data-plane pump; frames cross to the embedder over a bounded //! channel. All methods are safe to call from any single embedder thread. -use crate::config::{Mode, Role}; +use crate::config::{CompositorPref, Mode, Role}; use crate::error::{PunktfunkError, Result}; use crate::input::InputEvent; use crate::quic::{endpoint, io, Hello, Reconfigure, Reconfigured, Start, Welcome}; @@ -76,6 +76,7 @@ impl NativeClient { host: &str, port: u16, mode: Mode, + compositor: CompositorPref, pin: Option<[u8; 32]>, identity: Option<(String, String)>, timeout: Duration, @@ -110,6 +111,7 @@ impl NativeClient { host, port, mode, + compositor, pin, identity, frame_tx, @@ -309,6 +311,7 @@ struct WorkerArgs { host: String, port: u16, mode: Mode, + compositor: CompositorPref, pin: Option<[u8; 32]>, identity: Option<(String, String)>, frame_tx: SyncSender, @@ -328,6 +331,7 @@ async fn worker_main(args: WorkerArgs) { host, port, mode, + compositor, pin, identity, frame_tx, @@ -374,11 +378,18 @@ async fn worker_main(args: WorkerArgs) { &Hello { abi_version: crate::ABI_VERSION, mode, + compositor, } .encode(), ) .await?; let welcome = Welcome::decode(&io::read_msg(&mut recv).await?)?; + if welcome.compositor != CompositorPref::Auto { + tracing::info!( + compositor = welcome.compositor.as_str(), + "host resolved compositor" + ); + } // Reserve our data-plane port, then start the host. let probe = std::net::UdpSocket::bind("0.0.0.0:0")?; diff --git a/crates/punktfunk-core/src/config.rs b/crates/punktfunk-core/src/config.rs index feae7ef..cad5144 100644 --- a/crates/punktfunk-core/src/config.rs +++ b/crates/punktfunk-core/src/config.rs @@ -58,6 +58,78 @@ pub struct Mode { pub refresh_hz: u32, } +/// Which compositor backend a client would like the host to drive for its virtual output. +/// +/// Sent in [`Hello`](crate::quic::Hello) as a *preference* and echoed back — resolved to the +/// backend actually chosen — in [`Welcome`](crate::quic::Welcome). `Auto` (the default) lets the +/// host decide (auto-detect from the running desktop). A concrete preference is honored only if +/// that backend is available on the host right now; otherwise the host falls back to auto-detect +/// and reports the real choice in `Welcome`. The wire form is a single byte (`0 = Auto`, +/// `1..=4` concrete), appended to `Hello`/`Welcome` — older peers simply omit/ignore it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum CompositorPref { + /// Let the host pick (auto-detect from the running desktop / its configured default). + #[default] + Auto, + /// KWin / KDE Plasma. + Kwin, + /// wlroots (Sway / Hyprland). + Wlroots, + /// Mutter / GNOME. + Mutter, + /// gamescope (spawned nested — available wherever the binary is installed). + Gamescope, +} + +impl CompositorPref { + /// Wire byte. `0 = Auto`, `1 = Kwin`, `2 = Wlroots`, `3 = Mutter`, `4 = Gamescope`. + pub fn to_u8(self) -> u8 { + match self { + CompositorPref::Auto => 0, + CompositorPref::Kwin => 1, + CompositorPref::Wlroots => 2, + CompositorPref::Mutter => 3, + CompositorPref::Gamescope => 4, + } + } + + /// Inverse of [`to_u8`](Self::to_u8). An unknown byte decodes to `Auto` — forward-compatible: + /// a future concrete value a peer doesn't recognize degrades to "let the host decide". + pub fn from_u8(v: u8) -> Self { + match v { + 1 => CompositorPref::Kwin, + 2 => CompositorPref::Wlroots, + 3 => CompositorPref::Mutter, + 4 => CompositorPref::Gamescope, + _ => CompositorPref::Auto, + } + } + + /// Parse a CLI/config name (case-insensitive, with the usual desktop aliases). `None` for an + /// unrecognized name, so callers can error rather than silently defaulting to `Auto`. + pub fn from_name(s: &str) -> Option { + Some(match s.trim().to_ascii_lowercase().as_str() { + "auto" | "detect" | "default" => CompositorPref::Auto, + "kwin" | "kde" | "plasma" => CompositorPref::Kwin, + "wlroots" | "sway" | "hyprland" | "wlr" => CompositorPref::Wlroots, + "mutter" | "gnome" => CompositorPref::Mutter, + "gamescope" => CompositorPref::Gamescope, + _ => return None, + }) + } + + /// Canonical lowercase identifier (`"auto"`, `"kwin"`, `"wlroots"`, `"mutter"`, `"gamescope"`). + pub fn as_str(self) -> &'static str { + match self { + CompositorPref::Auto => "auto", + CompositorPref::Kwin => "kwin", + CompositorPref::Wlroots => "wlroots", + CompositorPref::Mutter => "mutter", + CompositorPref::Gamescope => "gamescope", + } + } +} + /// Per-block FEC parameters. Recovery count is derived from `fec_percent` exactly as /// GameStream does: `m = ceil(k * fec_percent / 100)`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] diff --git a/crates/punktfunk-core/src/lib.rs b/crates/punktfunk-core/src/lib.rs index 0253129..83acb23 100644 --- a/crates/punktfunk-core/src/lib.rs +++ b/crates/punktfunk-core/src/lib.rs @@ -39,7 +39,7 @@ pub mod session; pub mod stats; pub mod transport; -pub use config::{Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role}; +pub use config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role}; pub use error::{PunktfunkError, PunktfunkStatus, Result}; pub use session::{Frame, Session}; pub use stats::Stats; diff --git a/crates/punktfunk-core/src/quic.rs b/crates/punktfunk-core/src/quic.rs index 4e2d291..df90644 100644 --- a/crates/punktfunk-core/src/quic.rs +++ b/crates/punktfunk-core/src/quic.rs @@ -22,7 +22,7 @@ //! reported back for persisting). The data plane adds AES-GCM on top. //! All integers little-endian; every message is `u16 length || payload`. -use crate::config::{Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role}; +use crate::config::{CompositorPref, Config, FecConfig, FecScheme, Mode, ProtocolPhase, Role}; use crate::error::{PunktfunkError, Result}; /// Protocol magic + version, first bytes of the positional handshake (Hello/Welcome/Start). @@ -40,6 +40,11 @@ pub const CTL_MAGIC: &[u8; 4] = b"PKFc"; pub struct Hello { pub abi_version: u32, pub mode: Mode, + /// Which compositor the client would like the host to drive (`Auto` = host decides). The + /// host honors it only if that backend is available, else falls back and reports the real + /// choice in [`Welcome::compositor`]. Appended to the wire form — omitted by older clients + /// (decodes to `Auto`). + pub compositor: CompositorPref, } /// `host → client`: the complete session offer. @@ -56,6 +61,10 @@ pub struct Welcome { pub salt: [u8; 4], /// Seed/testing: how many frames the host will send (0 = unbounded). pub frames: u32, + /// The compositor the host actually resolved for this session (the client's + /// [`Hello::compositor`] preference if available, else the host's auto-detected choice). + /// Appended to the wire form — `Auto` when an older host omitted it (i.e. "unknown"). + pub compositor: CompositorPref, } /// `client → host`: data plane is bound, begin streaming. @@ -350,12 +359,13 @@ pub mod pake { impl Hello { pub fn encode(&self) -> Vec { - let mut b = Vec::with_capacity(20); + let mut b = Vec::with_capacity(21); b.extend_from_slice(MAGIC); b.extend_from_slice(&self.abi_version.to_le_bytes()); b.extend_from_slice(&self.mode.width.to_le_bytes()); b.extend_from_slice(&self.mode.height.to_le_bytes()); b.extend_from_slice(&self.mode.refresh_hz.to_le_bytes()); + b.push(self.compositor.to_u8()); // appended at offset 20 — older hosts read [0..20] and skip it b } @@ -371,6 +381,11 @@ impl Hello { height: u32at(12), refresh_hz: u32at(16), }, + // Optional trailing byte — an older client that omits it requests `Auto`. + compositor: b + .get(20) + .map(|&v| CompositorPref::from_u8(v)) + .unwrap_or_default(), }) } } @@ -395,13 +410,14 @@ impl Welcome { b.extend_from_slice(&self.key); b.extend_from_slice(&self.salt); b.extend_from_slice(&self.frames.to_le_bytes()); + b.push(self.compositor.to_u8()); // appended at offset 53 — older clients read [0..53] and skip it b } pub fn decode(b: &[u8]) -> Result { // Layout (LE): magic[0..4] abi[4..8] port[8..10] w[10..14] h[14..18] hz[18..22] // scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45] - // salt[45..49] frames[49..53]. + // salt[45..49] frames[49..53] compositor[53] (optional trailing byte). if b.len() < 53 || &b[0..4] != MAGIC { return Err(PunktfunkError::InvalidArg("bad Welcome")); } @@ -433,6 +449,12 @@ impl Welcome { key, salt, frames: u32at(49), + // Optional trailing byte — an older host that omits it leaves the resolved + // compositor unknown (`Auto`). + compositor: b + .get(53) + .map(|&v| CompositorPref::from_u8(v)) + .unwrap_or_default(), }) } @@ -931,6 +953,7 @@ mod tests { key: [7u8; 16], salt: [1, 2, 3, 4], frames: 600, + compositor: CompositorPref::Gamescope, }; assert_eq!(Welcome::decode(&w.encode()).unwrap(), w); } @@ -944,6 +967,7 @@ mod tests { height: 720, refresh_hz: 120, }, + compositor: CompositorPref::Kwin, }; assert_eq!(Hello::decode(&h.encode()).unwrap(), h); let s = Start { @@ -952,6 +976,74 @@ mod tests { assert_eq!(Start::decode(&s.encode()).unwrap(), s); } + #[test] + fn compositor_pref_wire_and_names() { + for p in [ + CompositorPref::Auto, + CompositorPref::Kwin, + CompositorPref::Wlroots, + CompositorPref::Mutter, + CompositorPref::Gamescope, + ] { + assert_eq!(CompositorPref::from_u8(p.to_u8()), p); + assert_eq!(CompositorPref::from_name(p.as_str()), Some(p)); + } + // Aliases + unknowns. + assert_eq!(CompositorPref::from_name("KDE"), Some(CompositorPref::Kwin)); + assert_eq!( + CompositorPref::from_name("sway"), + Some(CompositorPref::Wlroots) + ); + assert_eq!(CompositorPref::from_name("nope"), None); + // Unknown wire byte degrades to Auto (forward-compatible). + assert_eq!(CompositorPref::from_u8(200), CompositorPref::Auto); + } + + #[test] + fn hello_welcome_compositor_back_compat() { + // A new client/host appends one byte; the field is optional on decode, so a legacy + // peer's shorter message still decodes (compositor = Auto), and a legacy peer reading a + // new message ignores the trailing byte. Simulate both directions by truncation. + let h = Hello { + abi_version: 2, + mode: Mode { + width: 1920, + height: 1080, + refresh_hz: 60, + }, + compositor: CompositorPref::Mutter, + }; + let enc = h.encode(); + assert_eq!(enc.len(), 21); + // Legacy (20-byte) Hello → Auto, mode intact. + let legacy = Hello::decode(&enc[..20]).unwrap(); + assert_eq!(legacy.compositor, CompositorPref::Auto); + assert_eq!(legacy.mode, h.mode); + + let w = Welcome { + abi_version: 2, + udp_port: 7000, + mode: h.mode, + fec: FecConfig { + scheme: FecScheme::Gf16, + fec_percent: 20, + max_data_per_block: 4096, + }, + shard_payload: 1200, + encrypt: true, + key: [3u8; 16], + salt: [9, 8, 7, 6], + frames: 0, + compositor: CompositorPref::Kwin, + }; + let wenc = w.encode(); + assert_eq!(wenc.len(), 54); + let legacy_w = Welcome::decode(&wenc[..53]).unwrap(); + assert_eq!(legacy_w.compositor, CompositorPref::Auto); + assert_eq!(legacy_w.frames, 0); + assert_eq!(legacy_w.key, w.key); + } + #[test] fn reconfigure_roundtrip() { let rq = Reconfigure { @@ -992,6 +1084,7 @@ mod tests { height: 720, refresh_hz: 60, }, + compositor: CompositorPref::Auto, } .encode(); assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair"); diff --git a/crates/punktfunk-host/src/m3.rs b/crates/punktfunk-host/src/m3.rs index b7daf7a..86fc9b1 100644 --- a/crates/punktfunk-host/src/m3.rs +++ b/crates/punktfunk-host/src/m3.rs @@ -23,7 +23,7 @@ //! with GameStream pairing) and logs the SHA-256 fingerprint clients pin. use anyhow::{anyhow, Context, Result}; -use punktfunk_core::config::{FecConfig, FecScheme, Role}; +use punktfunk_core::config::{CompositorPref, FecConfig, FecScheme, Role}; use punktfunk_core::input::{InputEvent, InputKind}; use punktfunk_core::packet::{FLAG_PIC, FLAG_SOF}; use punktfunk_core::quic::{ @@ -395,6 +395,21 @@ async fn serve_session( ) .context("client-requested mode")?; + // Resolve the client's compositor preference to a concrete backend *now*, so the Welcome + // can report what we'll actually drive. Only the Virtual source has a compositor; the + // synthetic source has no virtual output. Blocking probes → spawn_blocking. + let compositor = match source { + M3Source::Virtual => { + let pref = hello.compositor; + Some( + tokio::task::spawn_blocking(move || resolve_compositor(pref)) + .await + .context("resolve compositor task")??, + ) + } + M3Source::Synthetic => None, + }; + // Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport). let probe = std::net::UdpSocket::bind("0.0.0.0:0")?; let udp_port = probe.local_addr()?.port(); @@ -420,19 +435,30 @@ async fn serve_session( M3Source::Synthetic => frames, M3Source::Virtual => 0, // unbounded — client streams until we close }, + // Report the resolved backend back to the client (Auto for the synthetic source). + compositor: compositor + .map(|c| c.as_pref()) + .unwrap_or(CompositorPref::Auto), }; io::write_msg(&mut send, &welcome.encode()).await?; let start = Start::decode(&io::read_msg(&mut recv).await?) .map_err(|e| anyhow!("Start decode: {e:?}"))?; - Ok::<_, anyhow::Error>((hello, welcome, udp_port, start)) + Ok::<_, anyhow::Error>((hello, welcome, udp_port, start, compositor)) }; - let (hello, welcome, udp_port, start) = tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake) - .await - .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; + let (hello, welcome, udp_port, start, compositor) = + tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake) + .await + .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; let (mut ctrl_send, mut ctrl_recv) = (send, recv); let client_udp = std::net::SocketAddr::new(peer.ip(), start.client_udp_port); - tracing::info!(%client_udp, udp_port, mode = ?hello.mode, "handshake complete — streaming"); + tracing::info!( + %client_udp, + udp_port, + mode = ?hello.mode, + compositor = compositor.map(|c| c.id()).unwrap_or("none"), + "handshake complete — streaming" + ); // Control task: the handshake stream stays open for mid-stream renegotiation. A // validated Reconfigure is acked, then handed to the data-plane thread, which rebuilds @@ -541,7 +567,16 @@ async fn serve_session( match source { M3Source::Synthetic => synthetic_stream(&mut session, frames, &stop_stream), M3Source::Virtual => { - virtual_stream(&mut session, mode, seconds, &stop_stream, &reconfig_rx) + let compositor = compositor + .expect("the Virtual source resolves a compositor during the handshake"); + virtual_stream( + &mut session, + mode, + seconds, + &stop_stream, + &reconfig_rx, + compositor, + ) } } }) @@ -805,6 +840,56 @@ fn synthetic_stream(session: &mut Session, frames: u32, stop: &AtomicBool) -> Re Ok(()) } +/// Pure selection: choose the backend to drive from the client's `pref`, the set `available` +/// right now, and the auto-`detected` default. A concrete preference wins only if it's available; +/// otherwise (and for `Auto`) fall back to the detected default. `None` only when nothing is +/// available *and* nothing was detected — the caller turns that into a handshake error. +fn pick_compositor( + pref: CompositorPref, + available: &[crate::vdisplay::Compositor], + detected: Option, +) -> Option { + if let Some(want) = crate::vdisplay::Compositor::from_pref(pref) { + if available.contains(&want) { + return Some(want); + } + } + detected +} + +/// Resolve the client's compositor preference to a concrete backend (the I/O shell around +/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log +/// whether the explicit request was honored or fell back. Runs blocking probes — call off the +/// async reactor (`spawn_blocking`). +fn resolve_compositor(pref: CompositorPref) -> Result { + use crate::vdisplay::Compositor; + let available = crate::vdisplay::available(); + let detected = crate::vdisplay::detect().ok(); + let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| { + anyhow!("no usable compositor (set PUNKTFUNK_COMPOSITOR or run inside a supported desktop)") + })?; + let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect(); + match Compositor::from_pref(pref) { + Some(want) if want == chosen => { + tracing::info!( + compositor = chosen.id(), + "honoring client compositor request" + ) + } + Some(want) => tracing::warn!( + requested = want.id(), + chosen = chosen.id(), + available = ?avail_ids, + "client-requested compositor unavailable — falling back to auto-detect" + ), + None => tracing::info!( + compositor = chosen.id(), + "auto-detected compositor (client: auto)" + ), + } + Ok(chosen) +} + /// Real capture→encode→punktfunk/1: a native virtual output at the client's mode, NVENC AUs /// stamped with the capture wall clock (the client derives per-frame pipeline latency). /// @@ -818,9 +903,13 @@ fn virtual_stream( seconds: u32, stop: &AtomicBool, reconfig: &std::sync::mpsc::Receiver, + compositor: crate::vdisplay::Compositor, ) -> Result<()> { - let compositor = crate::vdisplay::detect().context("detect compositor")?; - tracing::info!(?compositor, ?mode, "punktfunk/1 virtual display"); + tracing::info!( + compositor = compositor.id(), + ?mode, + "punktfunk/1 virtual display" + ); let mut vd = crate::vdisplay::open(compositor)?; let (mut capturer, mut enc, mut frame, mut interval) = build_pipeline_with_retry(&mut vd, mode)?; @@ -1000,6 +1089,36 @@ fn build_pipeline( mod tests { use super::*; + #[test] + fn compositor_resolution_precedence() { + use crate::vdisplay::Compositor::*; + // A concrete, available preference is honored. + assert_eq!( + pick_compositor(CompositorPref::Gamescope, &[Kwin, Gamescope], Some(Kwin)), + Some(Gamescope) + ); + // A concrete but UNavailable preference falls back to the detected default. + assert_eq!( + pick_compositor(CompositorPref::Mutter, &[Kwin, Gamescope], Some(Kwin)), + Some(Kwin) + ); + // Auto always uses the detected default. + assert_eq!( + pick_compositor(CompositorPref::Auto, &[Kwin, Gamescope], Some(Kwin)), + Some(Kwin) + ); + // Unavailable preference + nothing detected → None (caller errors the handshake). + assert_eq!( + pick_compositor(CompositorPref::Mutter, &[Gamescope], None), + None + ); + // Available preference still wins even when nothing was auto-detected. + assert_eq!( + pick_compositor(CompositorPref::Gamescope, &[Gamescope], None), + Some(Gamescope) + ); + } + #[test] fn permanent_errors_short_circuit_retry() { // Permanent: config / version / missing-tool — retrying within a session can't fix these. @@ -1287,7 +1406,16 @@ mod tests { // 2: anonymous session on a pairing-required host → rejected (connect fails). assert!( - NativeClient::connect("127.0.0.1", 19778, mode, None, None, timeout).is_err(), + NativeClient::connect( + "127.0.0.1", + 19778, + mode, + CompositorPref::Auto, + None, + None, + timeout + ) + .is_err(), "anonymous session must be rejected" ); @@ -1305,6 +1433,7 @@ mod tests { "127.0.0.1", 19778, mode, + CompositorPref::Auto, Some(host_fp), Some((cert.clone(), key.clone())), timeout, diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 1c67319..ef33c9e 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -120,6 +120,7 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { OpenApiRouter::new() .routes(routes!(get_health)) .routes(routes!(get_host_info)) + .routes(routes!(list_compositors)) .routes(routes!(get_status)) .routes(routes!(list_paired_clients)) .routes(routes!(unpair_client)) @@ -446,6 +447,52 @@ async fn get_host_info(State(st): State>) -> Json { }) } +/// A compositor backend the host can drive a virtual output on, and whether it's usable now. +#[derive(Serialize, ToSchema)] +struct AvailableCompositor { + /// Stable identifier (`"kwin"` | `"wlroots"` | `"mutter"` | `"gamescope"`) — pass this to a + /// client's `--compositor` flag. + id: String, + /// Human-readable label for UIs. + label: String, + /// Usable on this host right now: the live session's own compositor, or gamescope wherever + /// its binary is installed. + available: bool, + /// True for the backend an `Auto` (unspecified) request resolves to right now. + default: bool, +} + +/// Available compositor backends +/// +/// Lists every backend the host knows how to drive, flags which are usable right now, and marks +/// the one an unspecified (`Auto`) client request resolves to. Clients pass an `id` to their +/// `--compositor` flag (or `PUNKTFUNK_COMPOSITOR_*` over the C ABI) to request it. +#[utoipa::path( + get, + path = "/compositors", + tag = "host", + operation_id = "listCompositors", + responses( + (status = OK, description = "Compositor backends with availability + the auto-detected default", body = [AvailableCompositor]), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +async fn list_compositors() -> Json> { + let available = crate::vdisplay::available(); + let default = crate::vdisplay::detect().ok(); + Json( + crate::vdisplay::Compositor::all() + .into_iter() + .map(|c| AvailableCompositor { + id: c.id().into(), + label: c.label().into(), + available: available.contains(&c), + default: default == Some(c), + }) + .collect(), + ) +} + /// Live host status #[utoipa::path( get, @@ -771,6 +818,24 @@ mod tests { assert_eq!(body["codecs"], serde_json::json!(["h264", "h265", "av1"])); } + #[tokio::test] + async fn compositors_lists_all_backends_with_flags() { + let app = test_app(test_state(), None); + let (status, body) = send(&app, get_req("/api/v1/compositors")).await; + assert_eq!(status, StatusCode::OK); + let arr = body.as_array().expect("array"); + // Every backend the host knows, in stable order. + let ids: Vec<&str> = arr.iter().map(|c| c["id"].as_str().unwrap()).collect(); + assert_eq!(ids, ["kwin", "gamescope", "mutter", "wlroots"]); + for c in arr { + assert!(c["available"].is_boolean()); + assert!(c["default"].is_boolean()); + assert!(c["label"].as_str().is_some_and(|s| !s.is_empty())); + } + // At most one backend is the auto-detect default (none, if the test env has no desktop). + assert!(arr.iter().filter(|c| c["default"] == true).count() <= 1); + } + #[tokio::test] async fn status_reflects_runtime_state() { let state = test_state(); @@ -808,7 +873,14 @@ mod tests { x509_parser::pem::parse_x509_pem(state.identity.cert_pem.as_bytes()).unwrap(); let der = pem.contents.clone(); let fingerprint = hex::encode(Sha256::digest(&der)); - state.paired.lock().unwrap().push(der); + // Isolate from any real paired store on the dev box: AppState::new loads + // ~/.config/punktfunk/paired.json, so clear it before seeding our stand-in — otherwise + // a real GameStream-paired client lands at body[0] and this assertion sees its hash. + { + let mut p = state.paired.lock().unwrap(); + p.clear(); + p.push(der); + } let (status, body) = send(&app, get_req("/api/v1/clients")).await; assert_eq!(status, StatusCode::OK); diff --git a/crates/punktfunk-host/src/vdisplay.rs b/crates/punktfunk-host/src/vdisplay.rs index b5a63cb..c584e40 100644 --- a/crates/punktfunk-host/src/vdisplay.rs +++ b/crates/punktfunk-host/src/vdisplay.rs @@ -59,6 +59,91 @@ pub enum Compositor { Gamescope, } +impl Compositor { + /// Stable lowercase id used on the wire / management API (matches + /// [`punktfunk_core::CompositorPref::as_str`]). + pub fn id(self) -> &'static str { + match self { + Compositor::Kwin => "kwin", + Compositor::Wlroots => "wlroots", + Compositor::Mutter => "mutter", + Compositor::Gamescope => "gamescope", + } + } + + /// Human label for UIs. + pub fn label(self) -> &'static str { + match self { + Compositor::Kwin => "KWin / KDE Plasma", + Compositor::Wlroots => "wlroots (Sway / Hyprland)", + Compositor::Mutter => "Mutter / GNOME", + Compositor::Gamescope => "gamescope", + } + } + + /// The protocol [`punktfunk_core::CompositorPref`] naming this backend. + pub fn as_pref(self) -> punktfunk_core::CompositorPref { + use punktfunk_core::CompositorPref as P; + match self { + Compositor::Kwin => P::Kwin, + Compositor::Wlroots => P::Wlroots, + Compositor::Mutter => P::Mutter, + Compositor::Gamescope => P::Gamescope, + } + } + + /// The concrete backend a [`punktfunk_core::CompositorPref`] names, or `None` for `Auto`. + pub fn from_pref(p: punktfunk_core::CompositorPref) -> Option { + use punktfunk_core::CompositorPref as P; + Some(match p { + P::Auto => return None, + P::Kwin => Compositor::Kwin, + P::Wlroots => Compositor::Wlroots, + P::Mutter => Compositor::Mutter, + P::Gamescope => Compositor::Gamescope, + }) + } + + /// Every backend, in a stable display order (for enumeration / UIs). + pub fn all() -> [Compositor; 4] { + [ + Compositor::Kwin, + Compositor::Gamescope, + Compositor::Mutter, + Compositor::Wlroots, + ] + } +} + +/// The compositor backends usable on this host *right now*: gamescope wherever its binary is +/// installed (it spawns a nested session — independent of the running desktop), plus the live +/// session's own compositor (KWin / Mutter / wlroots) when the host runs inside it. Cheap, +/// side-effect-free probes — safe to call per management request. A concrete client preference +/// is validated against this set before it's honored (see the m3 handshake's resolution). +pub fn available() -> Vec { + #[cfg(target_os = "linux")] + { + let mut v = Vec::new(); + if kwin::is_available() { + v.push(Compositor::Kwin); + } + if gamescope::is_available() { + v.push(Compositor::Gamescope); + } + if mutter::is_available() { + v.push(Compositor::Mutter); + } + if wlroots::is_available() { + v.push(Compositor::Wlroots); + } + v + } + #[cfg(not(target_os = "linux"))] + { + Vec::new() + } +} + /// Detect the compositor to drive: `PUNKTFUNK_COMPOSITOR` override, else `XDG_CURRENT_DESKTOP`. pub fn detect() -> Result { if let Ok(v) = std::env::var("PUNKTFUNK_COMPOSITOR") { diff --git a/crates/punktfunk-host/src/vdisplay/gamescope.rs b/crates/punktfunk-host/src/vdisplay/gamescope.rs index 8630f27..29cd420 100644 --- a/crates/punktfunk-host/src/vdisplay/gamescope.rs +++ b/crates/punktfunk-host/src/vdisplay/gamescope.rs @@ -198,6 +198,17 @@ fn find_gamescope_node() -> Option { None } +/// gamescope is usable wherever its binary runs — it spawns its own nested session, so it does +/// not require any particular desktop to be running. Quiet (no version warning — that's for the +/// create path); just checks the binary executes. +pub fn is_available() -> bool { + std::process::Command::new("gamescope") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + /// Minimum gamescope that captures reliably: below 3.16.22, headless PipeWire capture deadlocks /// against PipeWire ≥ 1.6 (a loop-lock bug) and a stuck link head-blocks the whole daemon. const MIN_GAMESCOPE: (u32, u32, u32) = (3, 16, 22); @@ -255,7 +266,10 @@ mod tests { #[test] fn parses_version_banner() { - assert_eq!(parse_version("gamescope version 3.16.22"), Some((3, 16, 22))); + assert_eq!( + parse_version("gamescope version 3.16.22"), + Some((3, 16, 22)) + ); assert_eq!( parse_version("gamescope: version v3.15.9 (no PipeWire)"), Some((3, 15, 9)) diff --git a/crates/punktfunk-host/src/vdisplay/kwin.rs b/crates/punktfunk-host/src/vdisplay/kwin.rs index afd5853..b3ae402 100644 --- a/crates/punktfunk-host/src/vdisplay/kwin.rs +++ b/crates/punktfunk-host/src/vdisplay/kwin.rs @@ -307,6 +307,12 @@ pub fn probe() -> Result<()> { Ok(()) } +/// KWin is usable iff we're inside a KWin session exposing `zkde_screencast` — exactly what +/// [`probe`] checks, surfaced as a bool for compositor enumeration. +pub fn is_available() -> bool { + probe().is_ok() +} + fn run( width: u32, height: u32, diff --git a/crates/punktfunk-host/src/vdisplay/mutter.rs b/crates/punktfunk-host/src/vdisplay/mutter.rs index c899f17..b9d66e5 100644 --- a/crates/punktfunk-host/src/vdisplay/mutter.rs +++ b/crates/punktfunk-host/src/vdisplay/mutter.rs @@ -46,6 +46,15 @@ impl MutterDisplay { } } +/// Mutter is usable when the host runs inside a GNOME session (its `RecordVirtual` D-Bus API +/// drives the *live* compositor). Cheap signal: `XDG_CURRENT_DESKTOP` names GNOME — same basis +/// as [`super::detect`], avoiding a blocking D-Bus round-trip on the enumeration path. +pub fn is_available() -> bool { + std::env::var("XDG_CURRENT_DESKTOP") + .map(|d| d.to_ascii_uppercase().contains("GNOME")) + .unwrap_or(false) +} + impl VirtualDisplay for MutterDisplay { fn name(&self) -> &'static str { "mutter" diff --git a/crates/punktfunk-host/src/vdisplay/wlroots.rs b/crates/punktfunk-host/src/vdisplay/wlroots.rs index 45596ff..33d7488 100644 --- a/crates/punktfunk-host/src/vdisplay/wlroots.rs +++ b/crates/punktfunk-host/src/vdisplay/wlroots.rs @@ -62,6 +62,12 @@ impl WlrootsDisplay { } } +/// wlroots/Sway is usable when the host runs inside a Sway session — signalled by `SWAYSOCK` +/// (the IPC socket `swaymsg create_output` needs). Cheap env check for the enumeration path. +pub fn is_available() -> bool { + std::env::var_os("SWAYSOCK").is_some() +} + impl VirtualDisplay for WlrootsDisplay { fn name(&self) -> &'static str { "wlroots" diff --git a/docs/api/openapi.json b/docs/api/openapi.json index 7306135..d3cca74 100644 --- a/docs/api/openapi.json +++ b/docs/api/openapi.json @@ -103,6 +103,41 @@ } } }, + "/api/v1/compositors": { + "get": { + "tags": [ + "host" + ], + "summary": "Available compositor backends", + "description": "Lists every backend the host knows how to drive, flags which are usable right now, and marks\nthe one an unspecified (`Auto`) client request resolves to. Clients pass an `id` to their\n`--compositor` flag (or `PUNKTFUNK_COMPOSITOR_*` over the C ABI) to request it.", + "operationId": "listCompositors", + "responses": { + "200": { + "description": "Compositor backends with availability + the auto-detected default", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AvailableCompositor" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/health": { "get": { "tags": [ @@ -381,6 +416,34 @@ } } }, + "AvailableCompositor": { + "type": "object", + "description": "A compositor backend the host can drive a virtual output on, and whether it's usable now.", + "required": [ + "id", + "label", + "available", + "default" + ], + "properties": { + "available": { + "type": "boolean", + "description": "Usable on this host right now: the live session's own compositor, or gamescope wherever\nits binary is installed." + }, + "default": { + "type": "boolean", + "description": "True for the backend an `Auto` (unspecified) request resolves to right now." + }, + "id": { + "type": "string", + "description": "Stable identifier (`\"kwin\"` | `\"wlroots\"` | `\"mutter\"` | `\"gamescope\"`) — pass this to a\nclient's `--compositor` flag." + }, + "label": { + "type": "string", + "description": "Human-readable label for UIs." + } + } + }, "Health": { "type": "object", "description": "Liveness + version probe.", diff --git a/include/punktfunk_core.h b/include/punktfunk_core.h index 5cc4599..e6ad7c3 100644 --- a/include/punktfunk_core.h +++ b/include/punktfunk_core.h @@ -19,6 +19,24 @@ // added `punktfunk_pair` / `punktfunk_generate_identity` / `punktfunk_connection_request_mode`. #define ABI_VERSION 2 +// Compositor preference for [`punktfunk_connect_ex`] (`compositor` arg). `AUTO` lets the host +// pick (auto-detect from its running desktop); a concrete value is honored only if that backend +// is available on the host right now, else the host falls back to auto-detect. The resolved +// choice is reported back over the protocol (see `punktfunk/1` `Welcome`). +#define PUNKTFUNK_COMPOSITOR_AUTO 0 + +// KWin / KDE Plasma. +#define PUNKTFUNK_COMPOSITOR_KWIN 1 + +// wlroots (Sway / Hyprland). +#define PUNKTFUNK_COMPOSITOR_WLROOTS 2 + +// Mutter / GNOME. +#define PUNKTFUNK_COMPOSITOR_MUTTER 3 + +// gamescope (spawned nested). +#define PUNKTFUNK_COMPOSITOR_GAMESCOPE 4 + // 16-byte AEAD authentication tag appended by GCM. #define TAG_LEN 16 @@ -350,7 +368,8 @@ PunktfunkStatus punktfunk_get_stats(PunktfunkSession *s, PunktfunkStats *out); #if defined(PUNKTFUNK_FEATURE_QUIC) // Connect to a `punktfunk/1` host and start a session at `width`x`height`@`refresh_hz`. -// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. +// Blocks up to `timeout_ms` for the handshake. Returns NULL on failure. Equivalent to +// [`punktfunk_connect_ex`] with `compositor = PUNKTFUNK_COMPOSITOR_AUTO`. // // Trust: `pin_sha256` (NULL or 32 bytes) is the expected SHA-256 fingerprint of the host's // certificate — a mismatching host is rejected. NULL = trust on first use; persist the @@ -378,6 +397,27 @@ PunktfunkConnection *punktfunk_connect(const char *host, uint32_t timeout_ms); #endif +#if defined(PUNKTFUNK_FEATURE_QUIC) +// Like [`punktfunk_connect`], but requests a specific `compositor` backend on the host (one of +// the `PUNKTFUNK_COMPOSITOR_*` values). `PUNKTFUNK_COMPOSITOR_AUTO` (or any unrecognized value) +// lets the host decide; a concrete value is honored only if available, else the host falls back +// to auto-detect. The resolved choice is logged host-side and returned over the protocol. +// +// # Safety +// Same as [`punktfunk_connect`]. +PunktfunkConnection *punktfunk_connect_ex(const char *host, + uint16_t port, + uint32_t width, + uint32_t height, + uint32_t refresh_hz, + uint32_t compositor, + const uint8_t *pin_sha256, + uint8_t *observed_sha256_out, + const char *client_cert_pem, + const char *client_key_pem, + uint32_t timeout_ms); +#endif + #if defined(PUNKTFUNK_FEATURE_QUIC) // Generate a persistent client identity: a self-signed certificate + private key, both // PEM, NUL-terminated, written into the caller's buffers. Generate ONCE, store both diff --git a/web/messages/de.json b/web/messages/de.json index d158ff2..b320022 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -31,6 +31,11 @@ "host_codecs": "Codecs", "host_ports": "Ports", "host_uniqueid": "Eindeutige ID", + "host_compositors": "Compositoren", + "host_compositors_help": "Backends, auf denen der Host eine virtuelle Ausgabe erzeugen kann. Übergib eine ID an das --compositor-Flag eines Clients; der Host nutzt sie, falls verfügbar, sonst per Auto-Erkennung.", + "compositor_available": "Verfügbar", + "compositor_unavailable": "Nicht verfügbar", + "compositor_default": "Standard", "clients_title": "Gekoppelte Geräte", "clients_empty": "Noch keine gekoppelten Geräte.", "clients_name": "Name", diff --git a/web/messages/en.json b/web/messages/en.json index 76b2199..7981445 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -31,6 +31,11 @@ "host_codecs": "Codecs", "host_ports": "Ports", "host_uniqueid": "Unique ID", + "host_compositors": "Compositors", + "host_compositors_help": "Backends the host can drive a virtual output on. Pass an id to a client's --compositor flag; the host honors it if available, else auto-detects.", + "compositor_available": "Available", + "compositor_unavailable": "Unavailable", + "compositor_default": "Default", "clients_title": "Paired clients", "clients_empty": "No paired clients yet.", "clients_name": "Name", diff --git a/web/src/routes/host.tsx b/web/src/routes/host.tsx index fa4bec9..b80a2b4 100644 --- a/web/src/routes/host.tsx +++ b/web/src/routes/host.tsx @@ -1,5 +1,5 @@ import { createFileRoute } from '@tanstack/react-router' -import { useGetHostInfo } from '@/api/gen/host/host' +import { useGetHostInfo, useListCompositors } from '@/api/gen/host/host' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Badge } from '@/components/ui/badge' import { QueryState } from '@/components/query-state' @@ -11,6 +11,7 @@ export const Route = createFileRoute('/host')({ component: HostPage }) function HostPage() { useLocale() const host = useGetHostInfo() + const compositors = useListCompositors() const h = host.data return ( @@ -65,6 +66,37 @@ function HostPage() { )} + + + + {m.host_compositors()} + + +

{m.host_compositors_help()}

+ +
    + {compositors.data?.map((c) => ( +
  • +
    +
    + {c.label} + {c.default && {m.compositor_default()}} +
    + {c.id} +
    + + {c.available ? m.compositor_available() : m.compositor_unavailable()} + +
  • + ))} +
+
+
+
) }