Full project rename, decided 2026-06-10: - Crates/binaries: punktfunk-core / punktfunk-host / punktfunk-client-rs. - C ABI: punktfunk_* symbols, Punktfunk* types, include/punktfunk_core.h, PUNKTFUNK_FEATURE_QUIC guard (header regenerated; cbindgen renames updated, incl. PUNKTFUNK_BTN_*/PUNKTFUNK_AXIS_* wire constants). - Protocol: punktfunk/1 — control-plane magic LMN1 → PKF1, nonce salt lmn1 → pkf1. WIRE BREAK: clients must be rebuilt from this revision. - Env knobs: PUNKTFUNK_VIDEO_SOURCE / PUNKTFUNK_COMPOSITOR / PUNKTFUNK_ZEROCOPY / …. - Host config dir: ~/.config/punktfunk (the box's dir was migrated in place — the persistent identity is unchanged, pinned fingerprints stay valid). - Swift package: PunktfunkKit + PunktfunkCore.xcframework + PunktfunkConnection (Sources/PunktfunkClient app + tests renamed with it); build-xcframework.sh updated. - scripts/: 60-punktfunk.rules, punktfunk-host.service; OpenAPI doc regenerated. Also: scripts/headless/run-headless-kde.sh — full headless Plasma bringup. Root cause of "desktop but no apps/settings" over the stream: plasmashell launched without XDG_MENU_PREFIX=plasma-, so the launcher resolved a nonexistent applications.menu and rendered an empty menu. The script sets the complete KDE session env (menu prefix, KDE_FULL_SESSION, session version) and rebuilds ksycoca before starting plasmashell. Gate: 97/97 tests, clippy -D warnings (both feature sets), fmt, C-ABI harness PASS, zero lumen references left outside .git. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,181 @@
|
||||
//! gamescope virtual-display backend.
|
||||
//!
|
||||
//! Unlike KWin/Mutter (which create a virtual output at runtime via a protocol), gamescope is a
|
||||
//! micro-compositor we *spawn*: `gamescope --backend headless -W w -H h -r hz -- <app>`. It runs
|
||||
//! the app nested, composites at the requested size/refresh (so the source rate is the client's
|
||||
//! rate natively — no separate refresh step), and exports a built-in PipeWire node named
|
||||
//! `gamescope` (media.class `Video/Source`, BGRx/NV12, dmabuf or shm) on the user's PipeWire
|
||||
//! daemon. We discover that node and capture it like any other; the gamescope *process* is the
|
||||
//! keepalive — dropping the [`VirtualOutput`] kills it (tearing the output down).
|
||||
//!
|
||||
//! Requirements: gamescope built with PipeWire + libei input emulation (distro packages are);
|
||||
//! a usable Vulkan device (the NVIDIA render node). Headless capture on the proprietary NVIDIA
|
||||
//! driver is plausible-by-architecture but not a well-trodden path — validate empirically.
|
||||
//! Input is a gamescope-specific libei/EIS socket (`LIBEI_SOCKET`), wired separately (TODO).
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The gamescope virtual-display driver. Each [`create`](VirtualDisplay::create) spawns one
|
||||
/// headless gamescope process sized to the requested mode.
|
||||
pub struct GamescopeDisplay;
|
||||
|
||||
impl GamescopeDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(GamescopeDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for GamescopeDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"gamescope"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Attach to an already-running gamescope (debug / Steam-launched session) instead of
|
||||
// spawning one: PUNKTFUNK_GAMESCOPE_NODE=<pipewire node id>.
|
||||
if let Ok(id) = std::env::var("PUNKTFUNK_GAMESCOPE_NODE") {
|
||||
let node_id: u32 = id
|
||||
.parse()
|
||||
.context("PUNKTFUNK_GAMESCOPE_NODE must be a node id")?;
|
||||
tracing::info!(node_id, "gamescope: attaching to existing PipeWire node");
|
||||
return Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(()),
|
||||
});
|
||||
}
|
||||
let proc = GamescopeProc(spawn(mode.width, mode.height, mode.refresh_hz.max(1))?);
|
||||
// gamescope creates its PipeWire node a moment after start; poll for it (the proc is held
|
||||
// alive meanwhile, and killed if we give up).
|
||||
let node_id = wait_for_node(Duration::from_secs(15)).ok_or_else(|| {
|
||||
anyhow!(
|
||||
"gamescope PipeWire node did not appear within 15s — gamescope may have failed to \
|
||||
start or headless capture is unsupported on this GPU/driver (see /tmp/punktfunk-gamescope.log)"
|
||||
)
|
||||
})?;
|
||||
tracing::info!(
|
||||
node_id,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
"gamescope virtual output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(proc),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// File where the wrapper below writes gamescope's `LIBEI_SOCKET` (its EIS server socket),
|
||||
/// read by the libei injector to drive input into the nested app. See [`crate::inject`].
|
||||
pub const EI_SOCKET_FILE: &str = "/tmp/punktfunk-gamescope-ei";
|
||||
|
||||
/// Spawn `gamescope --backend headless -W w -H h -r hz -- <app>`. The app comes from
|
||||
/// `PUNKTFUNK_GAMESCOPE_APP` (default a no-op that just keeps gamescope alive — set it to a real
|
||||
/// game/GL app for actual content, e.g. `steam -gamepadui` for the SteamOS-like session).
|
||||
/// stdout/stderr go to `/tmp/punktfunk-gamescope.log`. The app is launched through a tiny shell
|
||||
/// wrapper that relays gamescope's `LIBEI_SOCKET` (set for its children) to [`EI_SOCKET_FILE`]
|
||||
/// so the input injector can connect to gamescope's EIS server from outside.
|
||||
fn spawn(w: u32, h: u32, hz: u32) -> Result<Child> {
|
||||
let app =
|
||||
std::env::var("PUNKTFUNK_GAMESCOPE_APP").unwrap_or_else(|_| "sleep infinity".to_string());
|
||||
let _ = std::fs::remove_file(EI_SOCKET_FILE); // stale socket path from a previous session
|
||||
let mut cmd = Command::new("gamescope");
|
||||
cmd.args(["--backend", "headless"])
|
||||
.args(["-W", &w.to_string()])
|
||||
.args(["-H", &h.to_string()])
|
||||
.args(["-r", &hz.to_string()])
|
||||
.args(["--xwayland-count", "1", "--"])
|
||||
.args([
|
||||
"sh",
|
||||
"-c",
|
||||
&format!("printf %s \"$LIBEI_SOCKET\" > {EI_SOCKET_FILE}; exec \"$@\""),
|
||||
"sh",
|
||||
])
|
||||
.args(app.split_whitespace())
|
||||
// Prefer the NVIDIA GL vendor for the nested session (harmless on a pure-NVIDIA box).
|
||||
.env("__GLX_VENDOR_LIBRARY_NAME", "nvidia");
|
||||
if let Ok(log) = std::fs::File::create("/tmp/punktfunk-gamescope.log") {
|
||||
if let Ok(log2) = log.try_clone() {
|
||||
cmd.stdout(Stdio::from(log)).stderr(Stdio::from(log2));
|
||||
}
|
||||
} else {
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
}
|
||||
tracing::info!(w, h, hz, %app, "spawning gamescope (headless)");
|
||||
cmd.spawn()
|
||||
.context("spawn gamescope (is it installed? `apt install gamescope`)")
|
||||
}
|
||||
|
||||
/// Wait for gamescope to report its PipeWire node. Authoritative source: gamescope's own log
|
||||
/// line `stream available on node ID: N` (its node carries `node.name=gamescope` on TWO objects
|
||||
/// — the adapter and the inner stream — and only the advertised id is the correct capture
|
||||
/// target). Falls back to `pw-dump` discovery if the log line doesn't show.
|
||||
fn wait_for_node(timeout: Duration) -> Option<u32> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(id) = node_from_log() {
|
||||
return Some(id);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return find_gamescope_node(); // last-resort fallback
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `stream available on node ID: N` from the spawned gamescope's log (ANSI-colored).
|
||||
fn node_from_log() -> Option<u32> {
|
||||
let log = std::fs::read_to_string("/tmp/punktfunk-gamescope.log").ok()?;
|
||||
for line in log.lines().rev() {
|
||||
if let Some(pos) = line.find("stream available on node ID:") {
|
||||
let tail = &line[pos + "stream available on node ID:".len()..];
|
||||
let digits: String = tail.chars().filter(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(id) = digits.parse() {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
|
||||
fn find_gamescope_node() -> Option<u32> {
|
||||
let out = Command::new("pw-dump").output().ok()?;
|
||||
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
for obj in dump.as_array()? {
|
||||
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
|
||||
continue;
|
||||
}
|
||||
let props = obj.get("info").and_then(|i| i.get("props"));
|
||||
let name = props
|
||||
.and_then(|p| p.get("node.name"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("");
|
||||
let class = props
|
||||
.and_then(|p| p.get("media.class"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("");
|
||||
if name == "gamescope" || (class == "Video/Source" && name.contains("gamescope")) {
|
||||
return obj.get("id").and_then(|i| i.as_u64()).map(|x| x as u32);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Owns the spawned gamescope process; killing it tears the virtual output down.
|
||||
struct GamescopeProc(Child);
|
||||
|
||||
impl Drop for GamescopeProc {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! KWin virtual-output backend via the privileged `zkde_screencast_unstable_v1` Wayland
|
||||
//! protocol (the mechanism KRdp / krfb-virtualmonitor use).
|
||||
//!
|
||||
//! `stream_virtual_output(name, width, height, scale, pointer)` asks KWin to create a new output
|
||||
//! sized to exactly `width`x`height`, rendered natively (no scaling), and hands back a PipeWire
|
||||
//! node for it. The node lives on the user's default PipeWire daemon, so [`VirtualOutput::remote_fd`]
|
||||
//! is `None` and capture connects to that daemon directly.
|
||||
//!
|
||||
//! Requirements: KWin must expose the privileged `zkde_screencast` global — a real Plasma session
|
||||
//! authorizes it for its own clients; the headless test exposes it to bare clients via
|
||||
//! `KWIN_WAYLAND_NO_PERMISSION_CHECKS=1`. The compositor backend must implement
|
||||
//! `createVirtualOutput`: the **DRM backend** (any version) or the **VirtualBackend since KWin
|
||||
//! 6.5.6** (`kwin_wayland --virtual`); on `--virtual` < 6.5.6 the request fails with
|
||||
//! "Could not find output". We talk raw Wayland on `$WAYLAND_DISPLAY`, so the host must run inside
|
||||
//! the KWin session's environment.
|
||||
|
||||
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::os::fd::{AsFd, AsRawFd};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use wayland_client::protocol::wl_registry::{self, WlRegistry};
|
||||
use wayland_client::{Connection, Dispatch, Proxy, QueueHandle};
|
||||
|
||||
// Generate the client bindings for the vendored protocol XML inline (no build.rs). Path is
|
||||
// relative to CARGO_MANIFEST_DIR. See wayland-rs' "implementing a custom protocol" docs.
|
||||
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
|
||||
pub mod zkde {
|
||||
use wayland_client;
|
||||
use wayland_client::protocol::*;
|
||||
|
||||
pub mod __interfaces {
|
||||
use wayland_client::protocol::__interfaces::*;
|
||||
wayland_scanner::generate_interfaces!("protocols/zkde-screencast-unstable-v1.xml");
|
||||
}
|
||||
use self::__interfaces::*;
|
||||
|
||||
wayland_scanner::generate_client_code!("protocols/zkde-screencast-unstable-v1.xml");
|
||||
}
|
||||
|
||||
use zkde::zkde_screencast_stream_unstable_v1::{
|
||||
Event as StreamEvent, ZkdeScreencastStreamUnstableV1 as ScreencastStream,
|
||||
};
|
||||
use zkde::zkde_screencast_unstable_v1::ZkdeScreencastUnstableV1 as Screencast;
|
||||
|
||||
/// `pointer` attachment mode (the protocol enum): render the cursor into the stream so the
|
||||
/// remote sees it move with injected input.
|
||||
const POINTER_EMBEDDED: u32 = 2;
|
||||
|
||||
/// The name we give the created output; KWin exposes it to output-management as `Virtual-<name>`.
|
||||
const VOUT_NAME: &str = "punktfunk";
|
||||
|
||||
/// Highest interface version we drive. KWin currently advertises 5; we rely on the `created`
|
||||
/// event (deprecated only since v6) for the node id, so cap the bind at 5.
|
||||
const MAX_VERSION: u32 = 5;
|
||||
|
||||
/// The KWin virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) spins up
|
||||
/// its own Wayland connection/thread that owns the resulting output.
|
||||
pub struct KwinDisplay;
|
||||
|
||||
impl KwinDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(KwinDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for KwinDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"kwin"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
let (width, height) = (mode.width, mode.height);
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-kwin-vout".into())
|
||||
.spawn(move || virtual_output_thread(width, height, setup_tx, stop_thread))
|
||||
.context("spawn KWin virtual-output thread")?;
|
||||
|
||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("KWin virtual output failed: {e}"),
|
||||
Err(_) => bail!("timed out creating the KWin virtual output"),
|
||||
};
|
||||
tracing::info!(node_id, width, height, "KWin virtual output ready");
|
||||
// KWin creates virtual outputs at a hardcoded 60 Hz and `stream_virtual_output` has no
|
||||
// refresh argument, so when the client wants more we install + select a custom mode
|
||||
// (supported on virtual outputs since KWin 6.6). Done before capture connects PipeWire so
|
||||
// the stream negotiates at the higher rate. First cut shells out to kscreen-doctor; the
|
||||
// in-process kde_output_management_v2 client is a follow-up.
|
||||
if mode.refresh_hz > 60 {
|
||||
set_custom_refresh(width, height, mode.refresh_hz);
|
||||
}
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(StopGuard(stop)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort: raise the just-created virtual output's refresh above KWin's default 60 Hz by
|
||||
/// installing + selecting a custom mode via `kscreen-doctor` (the output is `Virtual-<VOUT_NAME>`,
|
||||
/// refresh given in mHz). Failure leaves the source at 60 Hz — the stream still works, just capped.
|
||||
fn set_custom_refresh(width: u32, height: u32, hz: u32) {
|
||||
let output = format!("Virtual-{VOUT_NAME}");
|
||||
let mhz = hz.saturating_mul(1000);
|
||||
let run = |arg: String| {
|
||||
std::process::Command::new("kscreen-doctor")
|
||||
.arg(arg)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
// Add the custom mode (a fresh output has none), then select it.
|
||||
let _ = run(format!(
|
||||
"output.{output}.addCustomMode.{width}.{height}.{mhz}.full"
|
||||
));
|
||||
if run(format!("output.{output}.mode.{width}x{height}@{hz}")) {
|
||||
tracing::info!(output, hz, "KWin virtual output: custom refresh applied");
|
||||
} else {
|
||||
tracing::warn!(
|
||||
output,
|
||||
hz,
|
||||
"kscreen-doctor refresh set failed — source stays 60 Hz (is kscreen-doctor installed?)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
|
||||
/// drops the Wayland connection and makes KWin reclaim the output.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct State {
|
||||
screencast: Option<Screencast>,
|
||||
node_id: Option<u32>,
|
||||
failed: Option<String>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl Dispatch<WlRegistry, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
registry: &WlRegistry,
|
||||
event: wl_registry::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
qh: &QueueHandle<Self>,
|
||||
) {
|
||||
if let wl_registry::Event::Global {
|
||||
name,
|
||||
interface,
|
||||
version,
|
||||
} = event
|
||||
{
|
||||
if interface == Screencast::interface().name {
|
||||
let v = version.min(MAX_VERSION);
|
||||
state.screencast = Some(registry.bind::<Screencast, _, _>(name, v, qh, ()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manager has no events.
|
||||
impl Dispatch<Screencast, ()> for State {
|
||||
fn event(
|
||||
_: &mut Self,
|
||||
_: &Screencast,
|
||||
_: zkde::zkde_screencast_unstable_v1::Event,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
||||
impl Dispatch<ScreencastStream, ()> for State {
|
||||
fn event(
|
||||
state: &mut Self,
|
||||
_: &ScreencastStream,
|
||||
event: StreamEvent,
|
||||
_: &(),
|
||||
_: &Connection,
|
||||
_: &QueueHandle<Self>,
|
||||
) {
|
||||
match event {
|
||||
StreamEvent::Created { node } => state.node_id = Some(node),
|
||||
StreamEvent::Failed { error } => state.failed = Some(error),
|
||||
StreamEvent::Closed => state.closed = true,
|
||||
// `serial` (v6) — we use the node id from `created`, so ignore.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker thread: create a `width`x`height` virtual output on KWin, send its PipeWire node id
|
||||
/// back over `setup_tx`, then keep the Wayland connection alive (so the output isn't destroyed)
|
||||
/// until `stop` is set. Mirrors the portal thread's "park to keep the session alive".
|
||||
fn virtual_output_thread(
|
||||
width: u32,
|
||||
height: u32,
|
||||
setup_tx: Sender<Result<u32, String>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
if let Err(e) = run(width, height, &setup_tx, &stop) {
|
||||
// If we never delivered a node id, report the failure to the waiting opener.
|
||||
let _ = setup_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
}
|
||||
|
||||
fn run(
|
||||
width: u32,
|
||||
height: u32,
|
||||
setup_tx: &Sender<Result<u32, String>>,
|
||||
stop: &AtomicBool,
|
||||
) -> Result<()> {
|
||||
let conn = Connection::connect_to_env()
|
||||
.context("connect to KWin Wayland (is WAYLAND_DISPLAY set to the KWin socket?)")?;
|
||||
let mut queue = conn.new_event_queue();
|
||||
let qh = queue.handle();
|
||||
let _registry = conn.display().get_registry(&qh, ());
|
||||
|
||||
let mut state = State::default();
|
||||
queue.roundtrip(&mut state).context("registry roundtrip")?;
|
||||
|
||||
let screencast = state.screencast.clone().ok_or_else(|| {
|
||||
anyhow!(
|
||||
"KWin does not expose zkde_screencast_unstable_v1 (need a real KDE session, or run \
|
||||
KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test)"
|
||||
)
|
||||
})?;
|
||||
|
||||
// Create the virtual output sized to the client, cursor composited into the stream.
|
||||
let stream = screencast.stream_virtual_output(
|
||||
VOUT_NAME.to_string(),
|
||||
width as i32,
|
||||
height as i32,
|
||||
1.0, // scale (logical == physical)
|
||||
POINTER_EMBEDDED,
|
||||
&qh,
|
||||
(),
|
||||
);
|
||||
tracing::info!(
|
||||
width,
|
||||
height,
|
||||
"KWin: requested virtual output; awaiting PipeWire node"
|
||||
);
|
||||
|
||||
// Pump events until KWin reports the node id (or an error).
|
||||
let node_id = loop {
|
||||
queue
|
||||
.blocking_dispatch(&mut state)
|
||||
.context("wayland dispatch (awaiting created)")?;
|
||||
if let Some(node) = state.node_id {
|
||||
break node;
|
||||
}
|
||||
if let Some(e) = state.failed.take() {
|
||||
bail!("stream_virtual_output failed: {e}");
|
||||
}
|
||||
if state.closed {
|
||||
bail!("KWin closed the stream before it was created");
|
||||
}
|
||||
};
|
||||
setup_tx
|
||||
.send(Ok(node_id))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Keep the connection (and thus the virtual output) alive until told to stop, observing
|
||||
// `closed`. blocking_dispatch can't be interrupted, so poll the connection fd with a short
|
||||
// timeout so `stop` is honored within ~200 ms.
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
queue
|
||||
.dispatch_pending(&mut state)
|
||||
.context("dispatch_pending")?;
|
||||
if state.closed {
|
||||
tracing::warn!("KWin closed the virtual-output stream");
|
||||
break;
|
||||
}
|
||||
conn.flush().context("wayland flush")?;
|
||||
let Some(guard) = conn.prepare_read() else {
|
||||
continue; // events already queued — loop dispatches them
|
||||
};
|
||||
let mut pfd = libc::pollfd {
|
||||
fd: conn.as_fd().as_raw_fd(),
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
let r = unsafe { libc::poll(&mut pfd, 1, 200) };
|
||||
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
|
||||
let _ = guard.read();
|
||||
} // else: timeout or signal — drop the guard, re-check `stop`
|
||||
}
|
||||
|
||||
// Best-effort clean teardown; dropping the connection also makes KWin reclaim the output.
|
||||
stream.close();
|
||||
let _ = conn.flush();
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//! GNOME/Mutter virtual-display backend via Mutter's *direct* D-Bus APIs (the same path
|
||||
//! gnome-remote-desktop uses for headless sessions — not the xdg portal, which needs an
|
||||
//! interactive grant):
|
||||
//!
|
||||
//! 1. `org.gnome.Mutter.RemoteDesktop.CreateSession()` → a remote-desktop session (read its
|
||||
//! `SessionId`). The cast is anchored to it, and it's also the future input path.
|
||||
//! 2. `org.gnome.Mutter.ScreenCast.CreateSession({"remote-desktop-session-id": id})`.
|
||||
//! 3. `ScreenCast.Session.RecordVirtual({"cursor-mode": embedded})` → Mutter creates a **virtual
|
||||
//! monitor** and returns a Stream object.
|
||||
//! 4. `RemoteDesktop.Session.Start()` → the Stream signals `PipeWireStreamAdded(node_id)`.
|
||||
//!
|
||||
//! The virtual monitor's *size* follows the PipeWire format negotiation — Mutter adapts it to
|
||||
//! what the consumer asks for — so the client's exact WxH is plumbed into our consumer's format
|
||||
//! pod as the preferred size ([`VirtualOutput::preferred_mode`]) rather than passed here.
|
||||
//! Sessions die with the D-Bus connection, so a keepalive thread owns it (RAII teardown).
|
||||
//!
|
||||
//! Requires a running Mutter (`gnome-shell` session, or `gnome-shell --headless` for the
|
||||
//! headless host) on the session bus. GNOME is detected via `XDG_CURRENT_DESKTOP=GNOME` or
|
||||
//! forced with `PUNKTFUNK_COMPOSITOR=mutter`.
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ashpd::zbus;
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use zbus::zvariant::{OwnedObjectPath, Value};
|
||||
|
||||
const BUS_RD: &str = "org.gnome.Mutter.RemoteDesktop";
|
||||
const BUS_SC: &str = "org.gnome.Mutter.ScreenCast";
|
||||
|
||||
/// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends).
|
||||
const CURSOR_EMBEDDED: u32 = 1;
|
||||
|
||||
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
|
||||
/// keepalive thread owning the D-Bus sessions behind the virtual monitor.
|
||||
pub struct MutterDisplay;
|
||||
|
||||
impl MutterDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(MutterDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for MutterDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"mutter"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-mutter-vout".into())
|
||||
.spawn(move || session_thread(setup_tx, stop_thread))
|
||||
.context("spawn Mutter virtual-output thread")?;
|
||||
|
||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("Mutter virtual monitor failed: {e}"),
|
||||
Err(_) => bail!("timed out creating the Mutter virtual monitor"),
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
"Mutter virtual monitor ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: None,
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(StopGuard(stop)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Dropping this ends the keepalive thread, closing the D-Bus connection — Mutter then tears
|
||||
/// the remote-desktop + screencast sessions (and the virtual monitor) down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire
|
||||
/// node id, then hold the connection until stopped.
|
||||
fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>) {
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let session = match connect().await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("{e:#}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = setup_tx.send(Ok(session.node_id));
|
||||
// Park, keeping `session` (and its zbus connection) alive until told to stop.
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
// Best-effort explicit teardown before the connection drops.
|
||||
let _ = session.rd_session.call_method("Stop", &()).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// The live session objects (held for the stream's lifetime) + the PipeWire node id.
|
||||
struct MutterSession {
|
||||
rd_session: zbus::Proxy<'static>,
|
||||
_sc_session: zbus::Proxy<'static>,
|
||||
_conn: zbus::Connection,
|
||||
node_id: u32,
|
||||
}
|
||||
|
||||
/// Run the four-step handshake (see module docs).
|
||||
async fn connect() -> Result<MutterSession> {
|
||||
let conn = zbus::Connection::session()
|
||||
.await
|
||||
.context("connect session D-Bus")?;
|
||||
|
||||
// 1. RemoteDesktop session (the anchor; also the future input path).
|
||||
let rd = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_RD,
|
||||
"/org/gnome/Mutter/RemoteDesktop",
|
||||
"org.gnome.Mutter.RemoteDesktop",
|
||||
)
|
||||
.await
|
||||
.context("RemoteDesktop proxy (is gnome-shell / `gnome-shell --headless` running?)")?;
|
||||
let rd_path: OwnedObjectPath = rd
|
||||
.call("CreateSession", &())
|
||||
.await
|
||||
.context("RemoteDesktop.CreateSession")?;
|
||||
let rd_session = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_RD,
|
||||
rd_path,
|
||||
"org.gnome.Mutter.RemoteDesktop.Session",
|
||||
)
|
||||
.await?;
|
||||
let session_id: String = rd_session
|
||||
.get_property("SessionId")
|
||||
.await
|
||||
.context("read SessionId")?;
|
||||
|
||||
// 2. ScreenCast session anchored to it.
|
||||
let sc = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_SC,
|
||||
"/org/gnome/Mutter/ScreenCast",
|
||||
"org.gnome.Mutter.ScreenCast",
|
||||
)
|
||||
.await
|
||||
.context("ScreenCast proxy")?;
|
||||
let mut props: HashMap<&str, Value> = HashMap::new();
|
||||
props.insert("remote-desktop-session-id", Value::from(session_id));
|
||||
let sc_path: OwnedObjectPath = sc
|
||||
.call("CreateSession", &(props,))
|
||||
.await
|
||||
.context("ScreenCast.CreateSession")?;
|
||||
let sc_session = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_SC,
|
||||
sc_path,
|
||||
"org.gnome.Mutter.ScreenCast.Session",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 3. The virtual monitor. Size/refresh follow the PipeWire format negotiation.
|
||||
let mut rec: HashMap<&str, Value> = HashMap::new();
|
||||
rec.insert("cursor-mode", Value::from(CURSOR_EMBEDDED));
|
||||
let stream_path: OwnedObjectPath = sc_session
|
||||
.call("RecordVirtual", &(rec,))
|
||||
.await
|
||||
.context("Session.RecordVirtual")?;
|
||||
let stream = zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_SC,
|
||||
stream_path,
|
||||
"org.gnome.Mutter.ScreenCast.Stream",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 4. Subscribe to the node-id signal BEFORE starting, then start the (combined) session.
|
||||
let mut added = stream
|
||||
.receive_signal("PipeWireStreamAdded")
|
||||
.await
|
||||
.context("subscribe PipeWireStreamAdded")?;
|
||||
rd_session
|
||||
.call_method("Start", &())
|
||||
.await
|
||||
.context("RemoteDesktop.Session.Start")?;
|
||||
let msg = tokio::time::timeout(Duration::from_secs(10), added.next())
|
||||
.await
|
||||
.map_err(|_| anyhow!("PipeWireStreamAdded did not arrive within 10s"))?
|
||||
.ok_or_else(|| anyhow!("signal stream ended before PipeWireStreamAdded"))?;
|
||||
let (node_id,): (u32,) = msg
|
||||
.body()
|
||||
.deserialize()
|
||||
.context("PipeWireStreamAdded body")?;
|
||||
|
||||
Ok(MutterSession {
|
||||
rd_session,
|
||||
_sc_session: sc_session,
|
||||
_conn: conn,
|
||||
node_id,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user