refactor(host/W6.2): extract virtual-display orchestration into the pf-vdisplay crate
vdisplay.rs + vdisplay/* (the per-compositor Linux backends — KWin zkde-screencast,
wlroots swaymsg, Mutter RemoteDesktop, Hyprland — and the Windows IddCx/pf-vdisplay
driver backend, behind one VirtualDisplay trait; the mode-conflict admission
registry, the display policy/identity/custom-preset state, and the session-env /
gamescope routing) move into crates/pf-vdisplay (plan §W6). The DDC/CI panel-power
control (used only here) and the KWin zkde protocol XML move with it. This
completes the host-crate decomposition: capture, encode, inject, and vdisplay are
now four subsystem crates over the shared leaves, and punktfunk-host is the
orchestrator (serve/supervisor + native + gamestream + mgmt).
Coupling breaks (all down-only, cargo-tree acyclic):
- capture::dxgi identity -> pf_frame::dxgi; win_display/monitor_devnode/
console_session_mismatch -> pf-win-display leaf; can_open_another_session ->
pf-encode (the NVENC session-budget admission gate — acyclic peer edge).
- The registry's DisplayCreated/DisplayReleased emits into the host SSE event bus
invert to a leaf hook: pf-vdisplay emits a neutral DisplayEvent to a
host-registered DISPLAY_EVENT_SINK, so it never reaches the orchestrator's
events module.
- The IddCx driver module is renamed pf_vdisplay -> driver (its old name collided
with the crate name through the host's `mod vdisplay` shim glob).
The host keeps `mod vdisplay { pub use pf_vdisplay::* }` so every crate::vdisplay::*
path (serve/mgmt/native/the capture FrameChannelSender seam) is unchanged; the
heavy deps (wayland/ashpd/tokio + the zkde protocol) moved with the crate.
Co-authored: a fail-closed IOCTL-reply-length security fix (reject short/zeroed
pf-vdisplay driver replies before trusting protocol_version/target_id/wudf_pid/luid,
security-review 2026-07-17) rides this commit in the moved driver module.
Verified: Linux clippy -D warnings (pf-vdisplay + host nvenc,vulkan-encode,pyrowave
--all-targets) + pf-vdisplay 63/63 + host 167/167 tests; Windows clippy -D warnings
(pf-vdisplay --all-targets + host nvenc,amf-qsv --all-targets) Finished exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,290 @@
|
||||
//! gamescope **discovery + probes** (plan §W3, carved out of the backend): finding the compositor's
|
||||
//! PipeWire node (log line first, then a scoped `pw-dump` fallback), locating its live EIS/libei
|
||||
//! socket, the version gate, and the dedicated-session game-exit check. Pure read-side plumbing — it
|
||||
//! observes gamescope, never spawns or tears it down (that stays in [`super`]).
|
||||
|
||||
use super::*;
|
||||
|
||||
/// 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.
|
||||
/// B2 (game-exit detection): confirm a **dedicated** gamescope session's game has exited. gamescope is
|
||||
/// a single-app compositor — it exits when its nested app exits — so once capture is lost, THIS
|
||||
/// session's `node_id` not reappearing within a short confirmation window means the game quit (vs. a
|
||||
/// transient PipeWire hiccup). Scoped to the session's own `node_id` (via [`gamescope_node_present`]),
|
||||
/// so a **coexisting** gamescope (a second dedicated session, or the box's game-mode gamescope beside a
|
||||
/// non-Steam dedicated launch) doesn't mask the exit (review findings #4/#8). Returns `true` when the
|
||||
/// node stays absent across the window.
|
||||
pub(crate) fn game_session_exited(node_id: u32) -> bool {
|
||||
let deadline = Instant::now() + Duration::from_millis(1500);
|
||||
loop {
|
||||
if gamescope_node_present(node_id) {
|
||||
return false; // OUR node is (still) present → not an exit (transient loss)
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return true; // our node stayed gone across the window → the game exited
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll [`find_gamescope_node`] (unscoped) up to `timeout` — for the managed / SteamOS session, which
|
||||
/// logs to journald (no per-spawn file) and is single-session (no scoping needed).
|
||||
pub(super) fn poll_managed_node(timeout: Duration) -> Option<u32> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(id) = find_gamescope_node() {
|
||||
return Some(id);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn wait_for_node(
|
||||
timeout: Duration,
|
||||
log: &std::path::Path,
|
||||
child_pid: u32,
|
||||
) -> Option<u32> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(id) = node_from_log(log) {
|
||||
return Some(id);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last-resort fallback scoped to THIS spawn's process tree (A5), so a coexisting gamescope's
|
||||
// node isn't picked by mistake.
|
||||
return find_gamescope_node_scoped(Some(child_pid));
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse `stream available on node ID: N` from a spawned gamescope's per-instance log (ANSI-colored).
|
||||
fn node_from_log(log: &std::path::Path) -> Option<u32> {
|
||||
let log = std::fs::read_to_string(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
|
||||
}
|
||||
|
||||
/// Is a PipeWire node with exactly `node_id` present on the default daemon right now? Used by the
|
||||
/// keep-alive reuse liveness probe ([`GamescopeDisplay::kept_display_alive`]): a kept gamescope node
|
||||
/// vanishes when its nested game exits, so a missing id means "recreate, don't reuse the corpse".
|
||||
pub(super) fn gamescope_node_present(node_id: u32) -> bool {
|
||||
let Ok(out) = Command::new("pw-dump").arg(node_id.to_string()).output() else {
|
||||
// pw-dump unavailable → don't block reuse (mark_failed is the backstop on a genuinely dead node).
|
||||
return true;
|
||||
};
|
||||
let Ok(dump) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
||||
return true;
|
||||
};
|
||||
dump.as_array()
|
||||
.map(|objs| {
|
||||
objs.iter().any(|o| {
|
||||
o.get("id").and_then(|i| i.as_u64()) == Some(node_id as u64)
|
||||
&& o.get("type").and_then(|t| t.as_str()) == Some("PipeWire:Interface:Node")
|
||||
})
|
||||
})
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Find the `gamescope` `Video/Source` node id in a `pw-dump` snapshot of the default daemon.
|
||||
///
|
||||
/// `node.name=gamescope` appears on TWO objects (the adapter *and* the inner stream node); only
|
||||
/// the one whose `media.class` is `Video/Source` is a valid capture target — connecting to the
|
||||
/// other wedges the link. So we require `Video/Source` first and fall back to a bare name match
|
||||
/// only if no class-tagged node is present (older gamescope that doesn't set media.class).
|
||||
pub(super) fn find_gamescope_node() -> Option<u32> {
|
||||
find_gamescope_node_scoped(None)
|
||||
}
|
||||
|
||||
/// Like [`find_gamescope_node`], but when `scope` is `Some(pid)` only a node whose owning process
|
||||
/// (`application.process.id`) is `pid` or a descendant of it qualifies (A5 — a spawn's node must
|
||||
/// belong to OUR gamescope's process tree, so a coexisting foreign / other-session gamescope node is
|
||||
/// never mistaken for ours). `None` = any gamescope node (the managed/attach paths, single-session).
|
||||
fn find_gamescope_node_scoped(scope: Option<u32>) -> Option<u32> {
|
||||
let out = Command::new("pw-dump").output().ok()?;
|
||||
let dump: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
let nodes = dump.as_array()?;
|
||||
let node_props = |obj: &serde_json::Value| -> Option<(u32, String, String, Option<u32>)> {
|
||||
if obj.get("type").and_then(|t| t.as_str()) != Some("PipeWire:Interface:Node") {
|
||||
return None;
|
||||
}
|
||||
let id = obj.get("id").and_then(|i| i.as_u64())? as u32;
|
||||
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("")
|
||||
.to_string();
|
||||
let class = props
|
||||
.and_then(|p| p.get("media.class"))
|
||||
.and_then(|n| n.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
// PipeWire records the owning process id as a string or an int depending on version.
|
||||
let pid = props
|
||||
.and_then(|p| p.get("application.process.id"))
|
||||
.and_then(|v| {
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
.map(|n| n as u32)
|
||||
});
|
||||
Some((id, name, class, pid))
|
||||
};
|
||||
// A node is in-scope when no scope is asked, or its owning pid descends from the scope pid. When
|
||||
// the pid prop is absent (older gamescope / PipeWire) we DON'T exclude it — falling back to the
|
||||
// per-instance log is the primary addressing (design §7 risk note).
|
||||
let in_scope = |pid: Option<u32>| -> bool {
|
||||
match scope {
|
||||
None => true,
|
||||
Some(root) => pid.map(|p| descends_from(p, root)).unwrap_or(true),
|
||||
}
|
||||
};
|
||||
// Preferred: a Video/Source node named (or containing) "gamescope", in scope.
|
||||
for obj in nodes {
|
||||
if let Some((id, name, class, pid)) = node_props(obj) {
|
||||
if class == "Video/Source"
|
||||
&& (name == "gamescope" || name.contains("gamescope"))
|
||||
&& in_scope(pid)
|
||||
{
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: a node literally named "gamescope" with no usable class tag, in scope.
|
||||
for obj in nodes {
|
||||
if let Some((id, name, _, pid)) = node_props(obj) {
|
||||
if name == "gamescope" && in_scope(pid) {
|
||||
tracing::warn!(
|
||||
node_id = id,
|
||||
"gamescope node has no media.class=Video/Source tag — capturing it anyway"
|
||||
);
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Find the live gamescope EIS (libei) socket to inject into when ATTACHING to an existing
|
||||
/// session (the spawn path instead relays the nested gamescope's `LIBEI_SOCKET` through a file).
|
||||
///
|
||||
/// gamescope names its EIS socket `gamescope-<display>-ei` in `XDG_RUNTIME_DIR` (alongside the
|
||||
/// `gamescope-<display>` wayland socket). Stale sockets from dead sessions linger, so we don't
|
||||
/// trust the name — we `connect()` each candidate and keep the connectable ones, returning the
|
||||
/// most recently created (the live session). Returns the bare socket *name* (the injector
|
||||
/// resolves it against `XDG_RUNTIME_DIR`, matching libei's own `LIBEI_SOCKET` semantics).
|
||||
pub(super) fn find_gamescope_eis_socket() -> Option<String> {
|
||||
let runtime = std::env::var("XDG_RUNTIME_DIR").ok()?;
|
||||
let mut live: Vec<(std::time::SystemTime, String)> = Vec::new();
|
||||
for entry in std::fs::read_dir(&runtime).ok()?.flatten() {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
// The EIS socket itself, not its `.lock` sidecar or the bare wayland socket.
|
||||
if !(name.starts_with("gamescope-") && name.ends_with("-ei")) {
|
||||
continue;
|
||||
}
|
||||
// Connectable == a live listener is behind it (a dead session's socket refuses).
|
||||
if std::os::unix::net::UnixStream::connect(entry.path()).is_err() {
|
||||
continue;
|
||||
}
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
live.push((mtime, name));
|
||||
}
|
||||
live.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime)); // newest first
|
||||
live.into_iter().next().map(|(_, n)| n)
|
||||
}
|
||||
|
||||
/// 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(crate) 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);
|
||||
|
||||
/// Best-effort: warn loudly if the installed gamescope is older than [`MIN_GAMESCOPE`]. Parsing
|
||||
/// failures are silent (don't block a possibly-fine custom build) — this is a diagnostic, not a
|
||||
/// gate. Returns the parsed version when it could read one.
|
||||
pub(super) fn check_gamescope_version() -> Option<(u32, u32, u32)> {
|
||||
let out = Command::new("gamescope").arg("--version").output().ok()?;
|
||||
// gamescope prints the version banner to stderr on some builds, stdout on others.
|
||||
let text = format!(
|
||||
"{}{}",
|
||||
String::from_utf8_lossy(&out.stdout),
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let ver = parse_version(&text)?;
|
||||
if ver < MIN_GAMESCOPE {
|
||||
tracing::warn!(
|
||||
found = %format!("{}.{}.{}", ver.0, ver.1, ver.2),
|
||||
min = %format!("{}.{}.{}", MIN_GAMESCOPE.0, MIN_GAMESCOPE.1, MIN_GAMESCOPE.2),
|
||||
"gamescope is older than the minimum for reliable headless capture — expect a \
|
||||
capture deadlock against PipeWire ≥ 1.6 (a wedged link head-blocks the daemon); \
|
||||
upgrade gamescope or use PUNKTFUNK_COMPOSITOR=kwin|mutter"
|
||||
);
|
||||
}
|
||||
Some(ver)
|
||||
}
|
||||
|
||||
/// Extract the first `X.Y.Z` version triple from arbitrary text (e.g. `gamescope version 3.16.22`).
|
||||
fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||
for token in text.split(|c: char| !(c.is_ascii_digit() || c == '.')) {
|
||||
let mut parts = token.split('.');
|
||||
let (a, b, c) = (parts.next()?, parts.next(), parts.next());
|
||||
let (Some(b), Some(c)) = (b, c) else { continue };
|
||||
if let (Ok(a), Ok(b), Ok(c)) = (a.parse(), b.parse(), c.parse()) {
|
||||
return Some((a, b, c));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_version, MIN_GAMESCOPE};
|
||||
|
||||
#[test]
|
||||
fn parses_version_banner() {
|
||||
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))
|
||||
);
|
||||
assert_eq!(parse_version("3.16.20-1.fc41"), Some((3, 16, 20)));
|
||||
assert_eq!(parse_version("no version here"), None);
|
||||
assert_eq!(parse_version("only 3.16 here"), None); // needs a full triple
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_known_bad_versions() {
|
||||
// The 26.04-shipped 3.16.20 is below the minimum (PipeWire 1.6 deadlock).
|
||||
assert!(parse_version("gamescope version 3.16.20").unwrap() < MIN_GAMESCOPE);
|
||||
assert!(parse_version("gamescope version 3.16.22").unwrap() >= MIN_GAMESCOPE);
|
||||
assert!(parse_version("gamescope version 3.17.0").unwrap() >= MIN_GAMESCOPE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
//! Hyprland virtual-output backend via `hyprctl` IPC + the xdg ScreenCast portal
|
||||
//! (xdg-desktop-portal-hyprland / xdph). See `design/hyprland-support.md`.
|
||||
//!
|
||||
//! Hyprland dropped wlroots in v0.42 (aquamarine backend) but kept the client-facing wlr
|
||||
//! protocols, so it shares the wlr virtual-input path with sway — but it needs its own IPC and
|
||||
//! portal, so it is a **distinct backend** from [`super::wlroots`], not a branch inside it (D1):
|
||||
//!
|
||||
//! 1. `hyprctl output create headless PF-<n>` adds a named headless output — Hyprland supports
|
||||
//! **explicit names**, so no before/after diffing like sway's `HEADLESS-N` (D6). We poll
|
||||
//! `hyprctl -j monitors` until the name shows up.
|
||||
//! 2. A monitor rule sets the client's exact mode. [`set_monitor_rule`] uses `hyprctl keyword
|
||||
//! monitor NAME,WxH@Hz,auto,1` (the hyprlang path — the default config manager on every current
|
||||
//! release, ≥0.55 included) and falls back to the Lua `hyprctl eval 'hl.monitor{…}'` only for a
|
||||
//! user on the opt-in Lua config manager, confirming the output actually adopted the mode (D5).
|
||||
//! 3. The xdg ScreenCast portal (served by **xdph**) yields the output's PipeWire node. There is
|
||||
//! no GUI to pick an output headlessly, so xdph is steered through its **custom picker**: a
|
||||
//! managed config (`~/.config/hypr/xdph.conf`) points `screencopy:custom_picker_binary` at a tiny
|
||||
//! installed shim that cats a per-session selection file we write (`[SELECTION]screen:<NAME>`)
|
||||
//! right before the handshake — byte-for-byte the xdpw pattern, xdph's picker wire format.
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and runs
|
||||
//! `hyprctl output remove NAME`.
|
||||
//!
|
||||
//! Requirements: the host runs inside (or can reach) the Hyprland session — either
|
||||
//! `HYPRLAND_INSTANCE_SIGNATURE` is inherited, or [`is_available`] discovers it from
|
||||
//! `$XDG_RUNTIME_DIR/hypr/` and [`super::super::apply_session_env`] exports it for `hyprctl` — with
|
||||
//! the ScreenCast interface routed to xdph (`scripts/headless/portals.conf`).
|
||||
//!
|
||||
//! Contracts verified on **Hyprland 0.55.4 + xdph 1.3.x** (`design/hyprland-support.md` Phase 0):
|
||||
//! `hyprctl` subcommands / JSON shapes, the `[SELECTION]screen:<name>` picker format, the
|
||||
//! `~/.config/hypr/xdph.conf` path + `screencopy:custom_picker_binary` key, and that `eval` needs
|
||||
//! the Lua config manager. Not yet exercised end-to-end on real DRM hardware: a headless output's
|
||||
//! GBM/dmabuf allocation (fails on a nested/NVIDIA test box — Sunshine#4197); `set_monitor_rule`
|
||||
//! surfaces that as a clear error instead of streaming a 0×0 output.
|
||||
|
||||
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::{Arc, Once};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Per-session file the xdph custom picker reads the selected output from. We write
|
||||
/// `screen:<NAME>\n` here right before the portal handshake selects sources. Lives under
|
||||
/// `$XDG_RUNTIME_DIR` (per-user, 0700) — NOT a world-writable /tmp path another local user could
|
||||
/// pre-create or rewrite between our write and xdph's read (steer capture elsewhere). Mirrors the
|
||||
/// wlroots chooser file.
|
||||
fn selection_file() -> String {
|
||||
let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
||||
format!("{dir}/punktfunk-xdph-output")
|
||||
}
|
||||
|
||||
/// The installed custom-picker shim: a tiny script that cats [`selection_file`]. xdph runs
|
||||
/// `custom_picker_binary` and reads one selection line from its stdout; an empty read (no session
|
||||
/// has written the file) leaves xdph to its interactive picker — the graceful fallback.
|
||||
fn picker_shim_path() -> String {
|
||||
let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
||||
format!("{dir}/punktfunk-xdph-picker.sh")
|
||||
}
|
||||
|
||||
/// The picker line for output `name`. Verified against xdph 1.3.x / hyprland-share-picker on
|
||||
/// Hyprland 0.55.4: xdph reads the custom picker's stdout and requires the `[SELECTION]` marker
|
||||
/// followed by `screen:<name>` (or `window:<addr>` / `region:…`); anything else is rejected as
|
||||
/// "strange output" and falls back to the interactive picker. So a monitor selection is
|
||||
/// `[SELECTION]screen:<name>`.
|
||||
fn picker_selection_line(name: &str) -> String {
|
||||
format!("[SELECTION]screen:{name}\n")
|
||||
}
|
||||
|
||||
/// The managed xdph config: point the screencopy custom picker at our shim so headless output
|
||||
/// selection needs no GUI. xdph reads its config at startup, so a change restarts it (see
|
||||
/// [`ensure_xdph_config`]). The *selection* is the per-session file, not this static config.
|
||||
fn xdph_config() -> String {
|
||||
format!(
|
||||
"# managed by punktfunk (vdisplay/hyprland.rs) — headless per-session output selection.\n\
|
||||
screencopy {{\n\
|
||||
custom_picker_binary = {}\n\
|
||||
}}\n",
|
||||
picker_shim_path()
|
||||
)
|
||||
}
|
||||
|
||||
/// Monotonic per-process counter for headless output names (`PF-1`, `PF-2`, …). Named outputs kill
|
||||
/// the before/after diff race sway needs (D6).
|
||||
static OUTPUT_SEQ: AtomicU32 = AtomicU32::new(0);
|
||||
|
||||
fn next_output_name() -> String {
|
||||
format!("PF-{}", OUTPUT_SEQ.fetch_add(1, Ordering::Relaxed) + 1)
|
||||
}
|
||||
|
||||
/// The Hyprland virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) adds one
|
||||
/// named headless output and spins up a portal thread owning the cast on it.
|
||||
pub struct HyprlandDisplay;
|
||||
|
||||
impl HyprlandDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(HyprlandDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
/// Hyprland is usable when a live Hyprland instance for our uid is reachable — signalled by
|
||||
/// `HYPRLAND_INSTANCE_SIGNATURE` (inherited from the session) **or** a discoverable instance socket
|
||||
/// under `$XDG_RUNTIME_DIR/hypr/*/.socket.sock` (so the systemd `--user` host works without env
|
||||
/// import, unlike sway's `SWAYSOCK`; the signature is then exported by `apply_session_env`). Cheap,
|
||||
/// side-effect-free — safe on the enumeration path.
|
||||
pub fn is_available() -> bool {
|
||||
if std::env::var_os("HYPRLAND_INSTANCE_SIGNATURE").is_some() {
|
||||
return true;
|
||||
}
|
||||
let dir = match std::env::var_os("XDG_RUNTIME_DIR") {
|
||||
Some(d) => std::path::PathBuf::from(d).join("hypr"),
|
||||
None => return false,
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.any(|e| e.path().join(".socket.sock").exists())
|
||||
}
|
||||
|
||||
/// Pre-flight for the Hyprland backend: `hyprctl` must reach the compositor (a clear error now
|
||||
/// beats a create-time failure), and if the permission system is enforcing, warn about the silent
|
||||
/// black-frame / dropped-input failure mode.
|
||||
pub fn probe() -> Result<()> {
|
||||
hyprctl(&["-j", "version"]).context(
|
||||
"hyprctl not reachable — is Hyprland running and HYPRLAND_INSTANCE_SIGNATURE set? (the \
|
||||
host must run inside, or be able to reach, the Hyprland session)",
|
||||
)?;
|
||||
if let Some((maj, min, pat)) = hyprland_version() {
|
||||
tracing::info!(version = %format!("{maj}.{min}.{pat}"), "Hyprland backend ready");
|
||||
}
|
||||
warn_if_permissions_enforced();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl VirtualDisplay for HyprlandDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"hyprland"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Log the permission-system caveat once per process (silent black frames otherwise).
|
||||
preflight_once();
|
||||
|
||||
let name = next_output_name();
|
||||
hyprctl_dispatch(&["output", "create", "headless", &name]).with_context(|| {
|
||||
format!("hyprctl output create headless {name} (is hyprctl reachable?)")
|
||||
})?;
|
||||
// Own the output from here on so any later error (or drop) removes it.
|
||||
let output = OutputGuard(name.clone());
|
||||
wait_monitor_ready(&name, Duration::from_secs(5))
|
||||
.with_context(|| format!("waiting for headless output {name} to appear"))?;
|
||||
|
||||
// The client's exact mode (also the frame clock — a headless output is timer-paced from it).
|
||||
set_monitor_rule(&name, mode).with_context(|| format!("set monitor rule for {name}"))?;
|
||||
|
||||
// Steer xdph's custom picker at our new output, then run the portal handshake on its own
|
||||
// thread (it parks to keep the cast alive, like the other backends).
|
||||
ensure_xdph_config()?;
|
||||
let sel = selection_file();
|
||||
std::fs::write(&sel, picker_selection_line(&name))
|
||||
.with_context(|| format!("write {sel}"))?;
|
||||
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-hypr-vout".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread))
|
||||
.context("spawn hyprland portal thread")?;
|
||||
|
||||
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"),
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
output = %name,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
"hyprland headless output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(Keepalive {
|
||||
_stop: StopGuard(stop),
|
||||
_output: output,
|
||||
}),
|
||||
// Owned (the compositor output is ours to tear down), but not registry-poolable: the
|
||||
// portal fd can't be re-opened per attach, so the registry passes it through on
|
||||
// `remote_fd.is_some()` — same as wlroots.
|
||||
ownership: DisplayOwnership::Owned,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast), then
|
||||
/// remove the output (fields drop in declaration order).
|
||||
struct Keepalive {
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal then
|
||||
/// tears the screencast session down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the created headless output; dropping it removes it from Hyprland.
|
||||
struct OutputGuard(String);
|
||||
|
||||
impl Drop for OutputGuard {
|
||||
fn drop(&mut self) {
|
||||
match hyprctl_dispatch(&["output", "remove", &self.0]) {
|
||||
Ok(_) => tracing::info!(output = %self.0, "hyprland headless output removed"),
|
||||
Err(e) => {
|
||||
tracing::warn!(output = %self.0, error = %format!("{e:#}"), "output remove failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `hyprctl <args>`, returning stdout. `hyprctl` reads `HYPRLAND_INSTANCE_SIGNATURE` from the
|
||||
/// env (exported by `apply_session_env`) to reach the right instance socket. It exits non-zero on a
|
||||
/// hard failure, but for dispatch commands it can print an error with status 0 — see
|
||||
/// [`hyprctl_dispatch`].
|
||||
fn hyprctl(args: &[&str]) -> Result<String> {
|
||||
let out = Command::new("hyprctl")
|
||||
.args(args)
|
||||
.output()
|
||||
.context("run hyprctl (is Hyprland installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"hyprctl {:?} failed: {}{}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stdout).trim(),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Run a `hyprctl` **dispatch** command (`output …`, `keyword …`, `eval …`) that reports success by
|
||||
/// printing `ok`. hyprctl often exits 0 even when the command is rejected, printing the error to
|
||||
/// stdout, so treat a known error marker as failure (this is also how [`set_monitor_rule`] tells the
|
||||
/// two config eras apart).
|
||||
fn hyprctl_dispatch(args: &[&str]) -> Result<()> {
|
||||
let out = hyprctl(args)?;
|
||||
let t = out.trim();
|
||||
let lc = t.to_ascii_lowercase();
|
||||
if lc.contains("invalid")
|
||||
|| lc.contains("not found")
|
||||
|| lc.contains("couldn't")
|
||||
|| lc.contains("could not")
|
||||
|| lc.contains("unknown")
|
||||
|| lc.contains("no such")
|
||||
|| lc.contains("error")
|
||||
// `hyprctl eval` on a hyprlang (non-Lua) config: "eval is only supported with the lua
|
||||
// config manager" — a rejection hyprctl reports with exit 0 and no other marker.
|
||||
|| lc.contains("only supported")
|
||||
|| lc.contains("not supported")
|
||||
{
|
||||
bail!("hyprctl {:?} rejected: {t}", args);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Wait until the named headless output shows up in `hyprctl -j monitors` (it appears near-instantly
|
||||
/// in practice; poll briefly to be safe).
|
||||
fn wait_monitor_ready(name: &str, timeout: Duration) -> Result<()> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if monitor_exists(name)? {
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!("output create succeeded but monitor {name} never appeared");
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Is a monitor named `name` present in `hyprctl -j monitors` (JSON)?
|
||||
fn monitor_exists(name: &str) -> Result<bool> {
|
||||
let out = hyprctl(&["-j", "monitors"])?;
|
||||
let monitors: serde_json::Value =
|
||||
serde_json::from_str(&out).context("parse hyprctl -j monitors")?;
|
||||
Ok(monitors
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.any(|m| m.get("name").and_then(|n| n.as_str()) == Some(name))
|
||||
})
|
||||
.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Set the client's exact mode on `name`, supporting both config eras (D5). Encapsulates the whole
|
||||
/// era split (D5). `hyprctl keyword monitor NAME,WxH@Hz,auto,1` is the hyprlang path — the default
|
||||
/// config manager on **every** current release, including ≥0.55 (verified on 0.55.4: version does
|
||||
/// NOT imply the Lua era — a stock 0.55.4 rejects `eval` with "only supported with the lua config
|
||||
/// manager"). So we try `keyword` first and fall back to the Lua `hyprctl eval 'hl.monitor{…}'` only
|
||||
/// for a user who opted into the Lua config manager (where `keyword` is gone). Either way we confirm
|
||||
/// the output actually adopted the mode — some forms print `ok` for a command they ignored.
|
||||
///
|
||||
/// A headless output starts at 0×0 and only gets a framebuffer once a mode commits; if neither form
|
||||
/// makes it adopt a usable (non-zero) size the compositor couldn't back the mode (a headless GBM /
|
||||
/// dmabuf allocation failure — Sunshine#4197, seen on some NVIDIA setups), which we surface clearly
|
||||
/// rather than streaming a 0×0 corpse.
|
||||
fn set_monitor_rule(name: &str, mode: Mode) -> Result<()> {
|
||||
let hz = mode.refresh_hz.max(1);
|
||||
let spec = format!("{name},{}x{}@{hz},auto,1", mode.width, mode.height);
|
||||
let lua = format!(
|
||||
"hl.monitor{{ output = \"{name}\", mode = \"{}x{}@{hz}\", position = \"auto\", scale = 1 }}",
|
||||
mode.width, mode.height
|
||||
);
|
||||
let keyword: Vec<&str> = vec!["keyword", "monitor", &spec];
|
||||
let eval: Vec<&str> = vec!["eval", &lua];
|
||||
for a in [&keyword, &eval] {
|
||||
// A wrong-era command errors (`keyword` gone under Lua, or `eval` under hyprlang) — skip to
|
||||
// the other form. A command that's accepted then has up to the timeout to take effect.
|
||||
if hyprctl_dispatch(a).is_err() {
|
||||
continue;
|
||||
}
|
||||
if wait_exact_mode(name, mode, Duration::from_millis(1500)) {
|
||||
tracing::debug!(output = %name, cmd = ?a, w = mode.width, h = mode.height, "monitor adopted the requested mode");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Neither form produced the exact mode. Distinguish "usable but different size" (proceed with a
|
||||
// warning — a working stream beats none) from "0×0 / gone" (the output has no framebuffer at all).
|
||||
match monitor_size(name)? {
|
||||
Some((w, h)) if w > 0 && h > 0 => {
|
||||
tracing::warn!(
|
||||
output = %name,
|
||||
requested = %format!("{}x{}", mode.width, mode.height),
|
||||
got = %format!("{w}x{h}"),
|
||||
"Hyprland did not adopt the exact requested mode — streaming at the output's current size"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
_ => bail!(
|
||||
"headless output {name} never got a framebuffer (stayed 0x0) after the monitor rule for \
|
||||
{}x{}@{hz} — the compositor could not back the mode, likely a headless GBM/dmabuf \
|
||||
allocation failure (GPU driver; cf. Sunshine#4197). Check the Hyprland log.",
|
||||
mode.width,
|
||||
mode.height
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll until monitor `name` reports exactly `mode`'s width×height (the rule applies asynchronously),
|
||||
/// up to `timeout`. Returns `false` on timeout.
|
||||
fn wait_exact_mode(name: &str, mode: Mode, timeout: Duration) -> bool {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if matches!(monitor_size(name), Ok(Some((w, h))) if w == mode.width as u64 && h == mode.height as u64)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return false;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Monitor `name`'s current `(width, height)` from `hyprctl -j monitors all` (includes a disabled
|
||||
/// output), or `None` if it isn't present. A freshly-created headless output reports `0×0` until a
|
||||
/// mode commits.
|
||||
fn monitor_size(name: &str) -> Result<Option<(u64, u64)>> {
|
||||
let out = hyprctl(&["-j", "monitors", "all"])?;
|
||||
let monitors: serde_json::Value =
|
||||
serde_json::from_str(&out).context("parse hyprctl -j monitors")?;
|
||||
let Some(arr) = monitors.as_array() else {
|
||||
return Ok(None);
|
||||
};
|
||||
for m in arr {
|
||||
if m.get("name").and_then(|n| n.as_str()) == Some(name) {
|
||||
let w = m.get("width").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
let h = m.get("height").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||
return Ok(Some((w, h)));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// The running Hyprland `(major, minor, patch)` from `hyprctl -j version` (`tag` like `v0.55.4`),
|
||||
/// for a diagnostic log — the mode-rule path is version-independent (see [`set_monitor_rule`]).
|
||||
fn hyprland_version() -> Option<(u16, u16, u16)> {
|
||||
let out = hyprctl(&["-j", "version"]).ok()?;
|
||||
let json: serde_json::Value = serde_json::from_str(&out).ok()?;
|
||||
parse_version_tag(json.get("tag").and_then(|t| t.as_str())?)
|
||||
}
|
||||
|
||||
/// Parse a Hyprland `tag` (`v0.55.4`, or a dev `v0.41.2-13-gabcdef`) to `(major, minor, patch)`.
|
||||
fn parse_version_tag(tag: &str) -> Option<(u16, u16, u16)> {
|
||||
let t = tag.trim().trim_start_matches(['v', 'V']);
|
||||
let mut it = t.split(['.', '-', '_', '+']);
|
||||
let major = it.next()?.parse().ok()?;
|
||||
let minor = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
let patch = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
|
||||
Some((major, minor, patch))
|
||||
}
|
||||
|
||||
/// Log the permission-system caveat at most once per process: with
|
||||
/// `ecosystem.enforce_permissions = true` (0.49+, off by default), direct screencopy/virtual-input
|
||||
/// clients can be denied — and denial is **silent black frames / dropped input**, not an error.
|
||||
fn preflight_once() {
|
||||
static WARNED: Once = Once::new();
|
||||
WARNED.call_once(warn_if_permissions_enforced);
|
||||
}
|
||||
|
||||
fn warn_if_permissions_enforced() {
|
||||
let Ok(out) = hyprctl(&["-j", "getoption", "ecosystem:enforce_permissions"]) else {
|
||||
return;
|
||||
};
|
||||
let on = serde_json::from_str::<serde_json::Value>(&out)
|
||||
.ok()
|
||||
.and_then(|j| j.get("int").and_then(|v| v.as_i64()))
|
||||
.is_some_and(|v| v != 0);
|
||||
if on {
|
||||
tracing::warn!(
|
||||
"Hyprland ecosystem.enforce_permissions is ON — screencopy/virtual-input may be denied \
|
||||
as SILENT black frames / dropped input. Grant the host with hl.permission rules \
|
||||
(screencopy + virtual pointer/keyboard) — see docs/hyprland."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure xdph uses our custom picker: install the shim (once) and write the managed config,
|
||||
/// restarting xdph if the config changed (it reads config only at startup). Mirrors the wlroots
|
||||
/// `ensure_xdpw_config` pattern.
|
||||
fn ensure_xdph_config() -> Result<()> {
|
||||
// 1. Install the picker shim (idempotent — content is fixed).
|
||||
let shim = picker_shim_path();
|
||||
let sel = selection_file();
|
||||
let shim_body = format!("#!/bin/sh\nexec cat \"{sel}\" 2>/dev/null\n");
|
||||
if std::fs::read_to_string(&shim).is_ok_and(|c| c == shim_body) {
|
||||
// already installed
|
||||
} else {
|
||||
std::fs::write(&shim, &shim_body).with_context(|| format!("write {shim}"))?;
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&shim, std::fs::Permissions::from_mode(0o700))
|
||||
.with_context(|| format!("chmod {shim}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Write the managed xdph config and restart xdph on change.
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
|
||||
let dir = base.join("hypr");
|
||||
let path = dir.join("xdph.conf");
|
||||
let cfg = xdph_config();
|
||||
if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
|
||||
std::fs::write(&path, &cfg).with_context(|| format!("write {}", path.display()))?;
|
||||
tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-hyprland config");
|
||||
let _ = Command::new("systemctl")
|
||||
.args([
|
||||
"--user",
|
||||
"try-restart",
|
||||
"xdg-desktop-portal-hyprland.service",
|
||||
])
|
||||
.status();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The ScreenCast portal handshake — the xdg ScreenCast portal is backend-neutral (served here by
|
||||
/// xdph), so this mirrors the wlroots portal thread: it reports the fd + node id and parks until
|
||||
/// stopped (the zbus connection is the cast's lifetime). xdph answers source selection via our
|
||||
/// custom picker, no dialog. (Kept separate from wlroots' copy so each wlr-family backend stays
|
||||
/// self-owned per D1; unify if they ever diverge no further.)
|
||||
fn portal_thread(setup_tx: Sender<Result<(OwnedFd, u32), String>>, stop: Arc<AtomicBool>) {
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus background reader must be pumped across the
|
||||
// create_session → select_sources → start handshake (see capture/linux.rs).
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the hyprland backend/xdph?)",
|
||||
)?;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(CursorMode::Embedded)
|
||||
// xdph offers MONITOR; the custom picker selects our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (custom picker declined? check the xdph config/shim/selection file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped — the cast
|
||||
// is torn down when the connection drops.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn version_tag_parses_release_and_dev_builds() {
|
||||
assert_eq!(parse_version_tag("v0.55.0"), Some((0, 55, 0)));
|
||||
assert_eq!(parse_version_tag("0.41.2"), Some((0, 41, 2)));
|
||||
// Dev builds tack the commit distance + hash on with a dash.
|
||||
assert_eq!(parse_version_tag("v0.41.2-13-gabcdef"), Some((0, 41, 2)));
|
||||
// Missing patch defaults to 0; garbage is rejected.
|
||||
assert_eq!(parse_version_tag("v1.0"), Some((1, 0, 0)));
|
||||
assert_eq!(parse_version_tag("wat"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_names_are_unique_and_prefixed() {
|
||||
let a = next_output_name();
|
||||
let b = next_output_name();
|
||||
assert!(a.starts_with("PF-") && b.starts_with("PF-"));
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_line_carries_the_selection_marker() {
|
||||
// xdph requires the `[SELECTION]` prefix; a bare `screen:NAME` is rejected as strange output.
|
||||
assert_eq!(picker_selection_line("PF-1"), "[SELECTION]screen:PF-1\n");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
//! 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. It is a *restricted*
|
||||
//! protocol — KWin advertises it only to a client whose installed `.desktop` lists it under
|
||||
//! `X-KDE-Wayland-Interfaces` (KWin maps the connecting client to a `.desktop` by resolving
|
||||
//! `/proc/<pid>/exe` against `Exec=`, then caches the grant per-executable for the session's life).
|
||||
//! So an interactive Plasma session does NOT hand it to a bare client — the host packages ship
|
||||
//! `io.unom.Punktfunk.Host.desktop` (`Exec=/usr/bin/punktfunk-host`,
|
||||
//! `X-KDE-Wayland-Interfaces=zkde_screencast_unstable_v1,…`) so it is present before the host first
|
||||
//! connects. The headless test path instead 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)]
|
||||
// Every `unsafe` block in this file carries a `// SAFETY:` proof; enforce it (unsafe-proof program).
|
||||
#![deny(clippy::undocumented_unsafe_blocks)]
|
||||
|
||||
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. Carries the connecting client's cert fingerprint (set before
|
||||
/// [`create`](VirtualDisplay::create)) so a paired client gets a STABLE per-slot output NAME
|
||||
/// (`Virtual-punktfunk-<id>`) — KWin persists per-output config (scale/mode) keyed by name in
|
||||
/// `kwinoutputconfig.json`, so a stable name makes KDE reapply that client's scaling on reconnect
|
||||
/// (Stage 3). Each `create` spins up its own Wayland connection/thread that owns the output.
|
||||
#[derive(Default)]
|
||||
pub struct KwinDisplay {
|
||||
client_fp: Option<[u8; 32]>,
|
||||
/// The identity slot the last [`create`](VirtualDisplay::create) resolved (the per-client id, or
|
||||
/// `None` for shared/anonymous) — reported to the registry via [`last_identity_slot`] so it can key
|
||||
/// the group arrangement + `/display/state` slot to the same id this backend named the output with.
|
||||
last_slot: Option<u32>,
|
||||
/// The base output name the last `create` used (`punktfunk` / `punktfunk-<id>`) — so
|
||||
/// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-<name>`.
|
||||
last_name: Option<String>,
|
||||
/// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive`
|
||||
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
|
||||
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
|
||||
/// A backstop [`Drop`] runs it if the registry never took it (so a physical is never left dark).
|
||||
pending_restore: Option<Box<dyn FnOnce() + Send>>,
|
||||
}
|
||||
|
||||
impl Drop for KwinDisplay {
|
||||
fn drop(&mut self) {
|
||||
// Backstop only: the registry takes the restore right after `create` (moving it into the group),
|
||||
// so this is normally `None`. If some path skipped the take, re-enable here so a physical is
|
||||
// never stranded dark.
|
||||
if let Some(restore) = self.pending_restore.take() {
|
||||
restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KwinDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(KwinDisplay::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl VirtualDisplay for KwinDisplay {
|
||||
fn name(&self) -> &'static str {
|
||||
"kwin"
|
||||
}
|
||||
|
||||
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
|
||||
self.client_fp = fingerprint;
|
||||
}
|
||||
|
||||
fn last_identity_slot(&self) -> Option<u32> {
|
||||
self.last_slot
|
||||
}
|
||||
|
||||
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
|
||||
self.pending_restore.take()
|
||||
}
|
||||
|
||||
fn apply_position(&mut self, x: i32, y: i32) {
|
||||
let Some(name) = self.last_name.clone() else {
|
||||
return;
|
||||
};
|
||||
let output = format!("Virtual-{name}");
|
||||
// kscreen-doctor position syntax: `output.<name>.position.<x>,<y>`.
|
||||
let ok = std::process::Command::new("kscreen-doctor")
|
||||
.arg(format!("output.{output}.position.{x},{y}"))
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
tracing::info!(output, x, y, "KWin: placed output in the desktop layout");
|
||||
} else {
|
||||
tracing::warn!(output, x, y, "KWin: output position apply failed");
|
||||
}
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id →
|
||||
// `punktfunk-<id>` (KWin exposes `Virtual-punktfunk-<id>`, whose per-output config KWin
|
||||
// persists by name). Shared / anonymous → the base `punktfunk` (today's single name). Linux
|
||||
// defaults to Shared when unconfigured, so this is a no-op change until a policy opts in — AND
|
||||
// it fixes the latent clash where two concurrent sessions both used `Virtual-punktfunk`.
|
||||
let slot = crate::identity::resolve_slot(
|
||||
self.client_fp,
|
||||
(mode.width, mode.height),
|
||||
crate::policy::Identity::Shared,
|
||||
);
|
||||
self.last_slot = slot; // reported to the registry for the group arrangement + state slot
|
||||
let name = match slot {
|
||||
Some(id) => format!("{VOUT_NAME}-{id}"),
|
||||
None => VOUT_NAME.to_string(),
|
||||
};
|
||||
self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout)
|
||||
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);
|
||||
let name_thread = name.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-kwin-vout".into())
|
||||
.spawn(move || virtual_output_thread(width, height, name_thread, 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 above 60 Hz we install + select a custom mode (supported on virtual
|
||||
// outputs since KWin 6.6) 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. `set_custom_refresh` reads back and
|
||||
// returns what KWin *actually* achieved so the encoder paces to the real source rate (a
|
||||
// rejected custom mode leaves the output at 60 Hz). At ≤60 Hz there's nothing to install —
|
||||
// the source runs 60 Hz and the encoder downsamples — so carry the requested rate through.
|
||||
let achieved_hz = if mode.refresh_hz > 60 {
|
||||
set_custom_refresh(width, height, mode.refresh_hz, &name)
|
||||
} else {
|
||||
mode.refresh_hz
|
||||
};
|
||||
// Display-management topology (Stage 2): `Extend` leaves the streamed output an extension;
|
||||
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
|
||||
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
|
||||
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
|
||||
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean).
|
||||
use crate::policy::Topology;
|
||||
let disabled = match crate::effective_topology() {
|
||||
Topology::Exclusive => apply_virtual_primary(&name),
|
||||
Topology::Primary => {
|
||||
apply_virtual_primary_only(&name);
|
||||
Vec::new() // nothing disabled → nothing to restore
|
||||
}
|
||||
Topology::Extend | Topology::Auto => Vec::new(),
|
||||
};
|
||||
// Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session
|
||||
// `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive
|
||||
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
|
||||
// into the display group and runs once, when the group's LAST member is torn down (ordered before
|
||||
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
|
||||
self.pending_restore = (!disabled.is_empty()).then(|| {
|
||||
let disabled = disabled.clone();
|
||||
Box::new(move || reenable_outputs(&disabled)) as Box<dyn FnOnce() + Send>
|
||||
});
|
||||
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
|
||||
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
|
||||
Ok(VirtualOutput::owned(
|
||||
node_id,
|
||||
Some((mode.width, mode.height, achieved_hz)),
|
||||
Box::new(StopGuard { stop }),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
|
||||
/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE
|
||||
/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs.
|
||||
fn reenable_outputs(outputs: &[(String, String)]) {
|
||||
if outputs.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Enable FIRST, as a standalone apply — a bare `output.X.enable` always succeeds, so a physical
|
||||
// can never be left DARK. (Batching a possibly-stale `mode` arg into the same invocation risks
|
||||
// kscreen-doctor rejecting the whole config and leaving the output disabled.)
|
||||
let enable_args: Vec<String> = outputs
|
||||
.iter()
|
||||
.map(|(name, _)| format!("output.{name}.enable"))
|
||||
.collect();
|
||||
let _ = std::process::Command::new("kscreen-doctor")
|
||||
.args(&enable_args)
|
||||
.status();
|
||||
// THEN re-assert each captured mode, best-effort — a bare re-enable lets KWin fall back to the
|
||||
// EDID-preferred mode (a 120 Hz panel returns at ~60 Hz); this restores the exact refresh. The
|
||||
// output is enabled now, so the mode set is valid; a rejected mode just leaves KWin's default.
|
||||
let mode_args: Vec<String> = outputs
|
||||
.iter()
|
||||
.filter(|(_, mode)| !mode.is_empty())
|
||||
.map(|(name, mode)| format!("output.{name}.mode.{mode}"))
|
||||
.collect();
|
||||
if !mode_args.is_empty() {
|
||||
let _ = std::process::Command::new("kscreen-doctor")
|
||||
.args(&mode_args)
|
||||
.status();
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs at their captured modes (group empty)");
|
||||
}
|
||||
|
||||
/// 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), then **read back the active mode** and return the refresh KWin actually
|
||||
/// gave us. The apply command can report success yet leave the output at 60 Hz (mode rejected),
|
||||
/// and a silent rate mismatch surfaces downstream as judder / duplicated frames — so the caller
|
||||
/// paces the encoder to the *achieved* rate, not the requested one.
|
||||
fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 {
|
||||
let output = format!("Virtual-{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"
|
||||
));
|
||||
let applied = run(format!("output.{output}.mode.{width}x{height}@{hz}"));
|
||||
match read_active_refresh(&output) {
|
||||
Some(achieved) if achieved >= hz => {
|
||||
tracing::info!(
|
||||
output,
|
||||
requested = hz,
|
||||
achieved,
|
||||
"KWin virtual output: custom refresh applied"
|
||||
);
|
||||
achieved
|
||||
}
|
||||
Some(achieved) => {
|
||||
tracing::warn!(
|
||||
output,
|
||||
requested = hz,
|
||||
achieved,
|
||||
applied,
|
||||
"KWin virtual output refresh below requested — pacing the encoder to the achieved \
|
||||
rate (custom-mode install rejected? is kscreen-doctor up to date?)"
|
||||
);
|
||||
achieved.max(1)
|
||||
}
|
||||
None => {
|
||||
tracing::warn!(
|
||||
output,
|
||||
requested = hz,
|
||||
applied,
|
||||
"could not read back KWin virtual output refresh — assuming 60 Hz (is \
|
||||
kscreen-doctor installed?)"
|
||||
);
|
||||
60
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the active refresh (Hz, rounded) of `output` from `kscreen-doctor -j`. `None` if the
|
||||
/// tool, the output, or its current mode can't be found. Mode/output ids come through as either
|
||||
/// JSON strings or numbers depending on the KWin version, so both are accepted.
|
||||
fn read_active_refresh(output: &str) -> Option<u32> {
|
||||
let out = std::process::Command::new("kscreen-doctor")
|
||||
.arg("-j")
|
||||
.output()
|
||||
.ok()?;
|
||||
let doc: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
||||
let as_id = |v: &serde_json::Value| -> Option<String> {
|
||||
v.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| v.as_u64().map(|n| n.to_string()))
|
||||
};
|
||||
let o = doc
|
||||
.get("outputs")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|o| o.get("name").and_then(|n| n.as_str()) == Some(output))?;
|
||||
let current = o.get("currentModeId").and_then(as_id)?;
|
||||
let mode = o
|
||||
.get("modes")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|m| m.get("id").and_then(as_id).as_deref() == Some(current.as_str()))?;
|
||||
let hz = mode.get("refreshRate").and_then(|r| r.as_f64())?;
|
||||
Some(hz.round() as u32)
|
||||
}
|
||||
|
||||
/// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-<id>`,
|
||||
/// which KWin exposes as `Virtual-punktfunk` / `Virtual-punktfunk-<id>`. Group membership (§6.1) is
|
||||
/// recognised by this prefix, so we never have to thread the live set through the backend.
|
||||
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
|
||||
|
||||
/// The current mode of an output as a kscreen-doctor mode setter, from its `-j` entry — preferring
|
||||
/// the human `WxH@Hz` form (survives a mode-id re-enumeration across disable→enable) and falling back
|
||||
/// to the raw `currentModeId`. `None` if the current mode can't be resolved.
|
||||
fn output_current_mode_spec(o: &serde_json::Value) -> Option<String> {
|
||||
let as_id = |v: &serde_json::Value| -> Option<String> {
|
||||
v.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| v.as_u64().map(|n| n.to_string()))
|
||||
};
|
||||
let current = o.get("currentModeId").and_then(&as_id)?;
|
||||
let mode = o
|
||||
.get("modes")?
|
||||
.as_array()?
|
||||
.iter()
|
||||
.find(|m| m.get("id").and_then(&as_id).as_deref() == Some(current.as_str()))?;
|
||||
let human = (|| {
|
||||
let size = mode.get("size")?;
|
||||
let w = size.get("width").and_then(|v| v.as_u64())?;
|
||||
let h = size.get("height").and_then(|v| v.as_u64())?;
|
||||
let hz = mode.get("refreshRate").and_then(|r| r.as_f64())?.round() as u64;
|
||||
Some(format!("{w}x{h}@{hz}"))
|
||||
})();
|
||||
Some(human.unwrap_or(current))
|
||||
}
|
||||
|
||||
/// Currently-ENABLED outputs that are **not managed by us** — the headless session's bootstrap
|
||||
/// output(s) + any physical monitor, i.e. exactly what `exclusive` must disable — EACH PAIRED WITH ITS
|
||||
/// CURRENT MODE (`WxH@Hz`, empty if unresolved) so teardown can put it back at that exact refresh (a
|
||||
/// bare re-enable drops a 120 Hz panel to KWin's default ~60 Hz).
|
||||
/// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this
|
||||
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
|
||||
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_refresh`]).
|
||||
fn other_enabled_outputs() -> Vec<(String, String)> {
|
||||
let out = match std::process::Command::new("kscreen-doctor")
|
||||
.arg("-j")
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
let doc: serde_json::Value = match serde_json::from_slice(&out.stdout) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return Vec::new(),
|
||||
};
|
||||
doc.get("outputs")
|
||||
.and_then(|o| o.as_array())
|
||||
.map(|outs| {
|
||||
outs.iter()
|
||||
.filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
|
||||
.filter_map(|o| {
|
||||
let name = o.get("name").and_then(|n| n.as_str())?;
|
||||
(!name.starts_with(MANAGED_PREFIX)).then(|| {
|
||||
(
|
||||
name.to_string(),
|
||||
output_current_mode_spec(o).unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// True if any managed group member (the [`MANAGED_PREFIX`] family) is ALREADY the KWin primary —
|
||||
/// first-slot-wins support (§6.1) so a later exclusive session doesn't steal primary from the group's
|
||||
/// first member. Best-effort: if kscreen reports no primary flag we treat it as "none" (the session
|
||||
/// then sets itself primary — the pre-group behavior). Recent kscreen marks the primary with
|
||||
/// `"priority": 1`; older builds used a `"primary": true` bool — accept either.
|
||||
fn a_managed_output_is_primary() -> bool {
|
||||
let Ok(out) = std::process::Command::new("kscreen-doctor")
|
||||
.arg("-j")
|
||||
.output()
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
|
||||
return false;
|
||||
};
|
||||
doc.get("outputs")
|
||||
.and_then(|o| o.as_array())
|
||||
.map(|outs| {
|
||||
outs.iter().any(|o| {
|
||||
let managed = o
|
||||
.get("name")
|
||||
.and_then(|n| n.as_str())
|
||||
.is_some_and(|n| n.starts_with(MANAGED_PREFIX));
|
||||
let primary = o.get("primary").and_then(|p| p.as_bool()).unwrap_or(false)
|
||||
|| o.get("priority").and_then(|p| p.as_u64()) == Some(1);
|
||||
managed && primary
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so the managed group becomes
|
||||
/// the sole desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for
|
||||
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
|
||||
/// showing only the wallpaper) rather than failing the session.
|
||||
fn apply_virtual_primary(name: &str) -> Vec<(String, String)> {
|
||||
let ours = format!("Virtual-{name}");
|
||||
let kscreen = |args: &[String]| {
|
||||
std::process::Command::new("kscreen-doctor")
|
||||
.args(args)
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false)
|
||||
};
|
||||
// First-slot-wins (§6.1): only grab primary if no managed group member is primary yet — so a 2nd
|
||||
// exclusive session joins as a secondary monitor of the shared desktop instead of stealing the
|
||||
// shell off the 1st session's output. KWin usually then re-homes the desktop + disables the
|
||||
// bootstrap on its own; the belt-and-suspenders disable below covers the rest.
|
||||
if !a_managed_output_is_primary() {
|
||||
if !kscreen(&[format!("output.{ours}.primary")]) {
|
||||
tracing::warn!(
|
||||
"KWin: could not set the virtual output primary; client may see only the wallpaper"
|
||||
);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
// Disable everything still enabled that ISN'T a managed group member (bootstrap / physical), so
|
||||
// the group is unambiguously the desktop — never a sibling session's output (group-aware filter).
|
||||
// Each is captured WITH its current mode so teardown restores its real refresh, not KWin's default.
|
||||
let others = other_enabled_outputs();
|
||||
if !others.is_empty() {
|
||||
let args: Vec<String> = others
|
||||
.iter()
|
||||
.map(|(o, _mode)| format!("output.{o}.disable"))
|
||||
.collect();
|
||||
let _ = kscreen(&args);
|
||||
}
|
||||
tracing::info!(also_disabled = ?others, "KWin: streamed output set as the sole desktop");
|
||||
others
|
||||
}
|
||||
|
||||
/// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled
|
||||
/// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a
|
||||
/// physical screen stays usable. Nothing to restore on teardown (we disabled nothing).
|
||||
fn apply_virtual_primary_only(name: &str) {
|
||||
let ours = format!("Virtual-{name}");
|
||||
let ok = std::process::Command::new("kscreen-doctor")
|
||||
.arg(format!("output.{ours}.primary"))
|
||||
.status()
|
||||
.map(|s| s.success())
|
||||
.unwrap_or(false);
|
||||
if ok {
|
||||
tracing::info!("KWin: streamed output set primary (physical outputs kept)");
|
||||
} else {
|
||||
tracing::warn!("KWin: could not set the virtual output primary");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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. The topology **restore** is no
|
||||
/// longer bound here — it moved to the registry's display group (§6.1, [`reenable_outputs`]), which
|
||||
/// runs it once when the group's last member drops, BEFORE this keepalive is dropped.
|
||||
struct StopGuard {
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.stop.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,
|
||||
name: String,
|
||||
setup_tx: Sender<Result<u32, String>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
) {
|
||||
if let Err(e) = run(width, height, &name, &setup_tx, &stop) {
|
||||
// If we never delivered a node id, report the failure to the waiting opener.
|
||||
let _ = setup_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
}
|
||||
|
||||
/// Readiness probe: connect to the KWin Wayland socket, roundtrip the registry, and confirm
|
||||
/// the privileged `zkde_screencast` global is actually advertised. This is exactly what
|
||||
/// [`run`] needs before it can create a virtual output, so a session-bringup script can poll
|
||||
/// this to gate on the compositor being *ready* (not merely the socket existing) instead of
|
||||
/// racing it with a blind sleep. `Ok(())` = ready; `Err` = not ready / no global yet.
|
||||
pub fn probe() -> 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")?;
|
||||
if state.screencast.is_none() {
|
||||
bail!(
|
||||
"KWin is up but does not expose zkde_screencast_unstable_v1 to this client — KWin gates \
|
||||
it on the host's .desktop X-KDE-Wayland-Interfaces (install \
|
||||
io.unom.Punktfunk.Host.desktop with Exec=/usr/bin/punktfunk-host, then re-login so KWin \
|
||||
re-reads it — the grant is cached per-exe on first connect), or set \
|
||||
KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 for the headless test; needs KWin ≥ 6.5.6"
|
||||
);
|
||||
}
|
||||
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,
|
||||
name: &str,
|
||||
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 to this client — install the host's \
|
||||
.desktop (io.unom.Punktfunk.Host.desktop, X-KDE-Wayland-Interfaces) and re-login so \
|
||||
KWin authorizes it, or run KWin with KWIN_WAYLAND_NO_PERMISSION_CHECKS=1 (headless test)"
|
||||
)
|
||||
})?;
|
||||
|
||||
// Create the virtual output sized to the client, cursor composited into the stream.
|
||||
let stream = screencast.stream_virtual_output(
|
||||
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!(output = %name, node_id, "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,
|
||||
};
|
||||
// SAFETY: `&mut pfd` points at a single live, fully-initialized `libc::pollfd` on the stack, and
|
||||
// the count `1` matches that one-element array, so `poll` reads `fd`/`events` and writes `revents`
|
||||
// strictly within `pfd`. `pfd.fd` is the Wayland connection's fd, valid because `conn` (and the
|
||||
// `prepare_read` guard) are alive across the call. `poll` blocks up to 200 ms and writes only
|
||||
// `revents`; `pfd` outlives the synchronous call and aliases nothing (a fresh local).
|
||||
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(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MANAGED_PREFIX;
|
||||
|
||||
/// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled,
|
||||
/// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output
|
||||
/// (the Stage-3 naming would otherwise make a 2nd exclusive session black out the 1st).
|
||||
#[test]
|
||||
fn exclusive_disables_only_non_managed() {
|
||||
let enabled = [
|
||||
"Virtual-punktfunk", // base name (shared identity)
|
||||
"Virtual-punktfunk-1", // client A's per-slot output
|
||||
"Virtual-punktfunk-7", // client B's per-slot output
|
||||
"eDP-1", // a physical panel
|
||||
];
|
||||
let to_disable: Vec<&str> = enabled
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|n| !n.starts_with(MANAGED_PREFIX))
|
||||
.collect();
|
||||
assert_eq!(to_disable, vec!["eDP-1"]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,978 @@
|
||||
//! 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`.
|
||||
//!
|
||||
//! **Per-client scaling** (`identity` policy §5.4): GNOME persists per-monitor scale to
|
||||
//! `monitors.xml` keyed by connector+vendor+product+**serial**, but Mutter mints a fresh serial
|
||||
//! (`0x%.6x`, a per-shell counter) for every `RecordVirtual` monitor and the API offers no way to
|
||||
//! pass a stable identity — so GNOME's own persistence can never rematch our virtual output. The
|
||||
//! host persists the scale instead ([`identity::ScaleMap`](crate::identity), keyed per
|
||||
//! client / per the policy): reapplied at connect via the mode's `preferred-scale` plus the
|
||||
//! topology `ApplyMonitorsConfig`, and the user's mid-session changes are polled from
|
||||
//! DisplayConfig and written back.
|
||||
|
||||
use super::{Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use ashpd::zbus;
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
use zbus::zvariant::{OwnedObjectPath, OwnedValue, Value};
|
||||
|
||||
const BUS_RD: &str = "org.gnome.Mutter.RemoteDesktop";
|
||||
const BUS_SC: &str = "org.gnome.Mutter.ScreenCast";
|
||||
const BUS_DC: &str = "org.gnome.Mutter.DisplayConfig";
|
||||
/// `ApplyMonitorsConfig` method: 1 = temporary (auto-reverts on the next monitor change —
|
||||
/// e.g. when our virtual output is torn down — so we never persist a layout to monitors.xml).
|
||||
const APPLY_TEMPORARY: u32 = 1;
|
||||
|
||||
/// Mutter cursor mode: render the cursor into the stream (matches the KWin/gamescope backends).
|
||||
const CURSOR_EMBEDDED: u32 = 1;
|
||||
|
||||
/// Serializes, process-wide, every Mutter operation that adds/removes a virtual monitor or applies
|
||||
/// a monitor configuration. Each of these makes Mutter rebuild its monitor topology, and
|
||||
/// *concurrent* rebuilds have segfaulted gnome-shell on-glass twice now: the teardown-side race is
|
||||
/// documented at the teardown below, and on 2026-07-10 three simultaneous session setups (three
|
||||
/// `RecordVirtual` calls within ~200 µs plus an `ApplyMonitorsConfig`) crashed the shell inside
|
||||
/// `meta_monitor_manager_rebuild` — dropping the box to the GDM greeter until a DM restart. One
|
||||
/// mutation at a time also keeps [`wait_virtual_connector`] sound: with two virtual outputs
|
||||
/// appearing at once, "the connector absent from MY pre-snapshot" can name a sibling's monitor.
|
||||
/// Each session runs on its own dedicated thread (see [`session_thread`]), so blocking on a std
|
||||
/// mutex — including across the awaits of its single-threaded setup future — is safe.
|
||||
static TOPOLOGY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// 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 {
|
||||
/// Whether this display is the FIRST of its group (§6.1) — set by the registry before `create`.
|
||||
/// A later sibling **extends** into the already-exclusive desktop instead of re-applying the
|
||||
/// sole-monitor config (which would disable the first session's virtual). Defaults true (a lone
|
||||
/// session establishes topology as before).
|
||||
first_in_group: bool,
|
||||
/// The connecting client's cert fingerprint (set before [`create`](VirtualDisplay::create)) —
|
||||
/// keys the per-client persisted **scale** (GNOME can't persist it itself: Mutter mints a fresh
|
||||
/// EDID serial per `RecordVirtual` monitor, so `monitors.xml` never rematches; see
|
||||
/// [`identity::ScaleMap`](crate::identity)).
|
||||
client_fp: Option<[u8; 32]>,
|
||||
/// The identity slot the last `create` resolved — reported to the registry via
|
||||
/// [`last_identity_slot`](VirtualDisplay::last_identity_slot) to key the group arrangement +
|
||||
/// `/display/state` slot, like the KWin backend.
|
||||
last_slot: Option<u32>,
|
||||
}
|
||||
|
||||
impl MutterDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(MutterDisplay {
|
||||
first_in_group: true,
|
||||
client_fp: None,
|
||||
last_slot: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"
|
||||
}
|
||||
|
||||
fn set_first_in_group(&mut self, first: bool) {
|
||||
self.first_in_group = first;
|
||||
}
|
||||
|
||||
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
|
||||
self.client_fp = fingerprint;
|
||||
}
|
||||
|
||||
fn last_identity_slot(&self) -> Option<u32> {
|
||||
self.last_slot
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
// Identity (§5.4): resolve the client's stable slot per the `identity` policy (Linux
|
||||
// defaults to Shared when unconfigured, like KWin) — it keys the registry's group
|
||||
// arrangement/state. Mutter can't carry the slot into the monitor's EDID (RecordVirtual
|
||||
// owns the identity), so the per-client scaling that policy promises is host-persisted
|
||||
// instead: the session thread reapplies the remembered scale and records the user's
|
||||
// in-session changes under `scale_key`.
|
||||
self.last_slot = crate::identity::resolve_slot(
|
||||
self.client_fp,
|
||||
(mode.width, mode.height),
|
||||
crate::policy::Identity::Shared,
|
||||
);
|
||||
let scale_key = crate::identity::scale_key(
|
||||
self.client_fp,
|
||||
(mode.width, mode.height),
|
||||
crate::policy::Identity::Shared,
|
||||
);
|
||||
let remembered_scale = crate::identity::scales()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.get(&scale_key);
|
||||
if let Some(scale) = remembered_scale {
|
||||
tracing::info!(scale, "mutter: reapplying the client's saved display scale");
|
||||
}
|
||||
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 first_in_group = self.first_in_group;
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-mutter-vout".into())
|
||||
.spawn(move || {
|
||||
session_thread(
|
||||
setup_tx,
|
||||
stop_thread,
|
||||
mode,
|
||||
first_in_group,
|
||||
scale_key,
|
||||
remembered_scale,
|
||||
)
|
||||
})
|
||||
.context("spawn Mutter virtual-output thread")?;
|
||||
|
||||
// 45 s (was 20 s): setups now queue on TOPOLOGY_LOCK, so a session behind a slow sibling
|
||||
// (whose guard spans up to a ~10 s stream wait + 6 s connector wait + the apply) must
|
||||
// outwait it plus its own handshake before this fires.
|
||||
let node_id = match setup_rx.recv_timeout(Duration::from_secs(45)) {
|
||||
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::owned(
|
||||
node_id,
|
||||
Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
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. `first_in_group` gates the topology change (a
|
||||
/// non-first sibling extends into the group's already-exclusive desktop instead of re-clobbering it).
|
||||
/// `scale_key`/`remembered_scale` carry the per-client persisted scale: reapplied at connect,
|
||||
/// and the user's in-session changes are recorded back under the key (GNOME itself can't — see
|
||||
/// [`identity::ScaleMap`](crate::identity)).
|
||||
// TOPOLOGY_LOCK is deliberately held across the awaits of the setup/teardown sequences: each
|
||||
// session owns this dedicated OS thread and its own single-future runtime, so the guard never
|
||||
// blocks a shared executor — it blocks exactly the sibling session threads, which is the point
|
||||
// (see TOPOLOGY_LOCK).
|
||||
#[allow(clippy::await_holding_lock)]
|
||||
fn session_thread(
|
||||
setup_tx: Sender<Result<u32, String>>,
|
||||
stop: Arc<AtomicBool>,
|
||||
mode: Mode,
|
||||
first_in_group: bool,
|
||||
scale_key: String,
|
||||
remembered_scale: Option<f64>,
|
||||
) {
|
||||
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 {
|
||||
// The whole setup — pre-snapshot → RecordVirtual → ApplyMonitorsConfig — is one
|
||||
// read-modify-write on Mutter's monitor state; hold TOPOLOGY_LOCK across it so concurrent
|
||||
// sessions can't interleave rebuilds (gnome-shell SIGSEGV) or poison each other's
|
||||
// connector diffs. Released before the keepalive park below.
|
||||
let topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Display-management topology (Stage 2): the console policy's level, resolved to a concrete
|
||||
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
|
||||
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
|
||||
// SOLE output (physicals disabled). `Auto` never reaches here — it's resolved upstream.
|
||||
use crate::policy::Topology;
|
||||
let topo = crate::effective_topology();
|
||||
let topo_policy = matches!(topo, Topology::Primary | Topology::Exclusive);
|
||||
// Group-aware (§6.1): only the FIRST display of the group establishes the topology. A later
|
||||
// sibling extends into the already-exclusive desktop — re-applying the sole-monitor config would
|
||||
// disable the first session's virtual output (Mutter connectors are un-nameable, so we can't
|
||||
// build a config that keeps all group virtuals; skipping is the safe choice). *Concurrent
|
||||
// Mutter exclusive is on-glass-validation-pending; the APPLY_TEMPORARY revert when the FIRST
|
||||
// session leaves under a live sibling is a documented residual (design §7).*
|
||||
let want_config = first_in_group && topo_policy;
|
||||
if topo_policy && !first_in_group {
|
||||
tracing::info!(
|
||||
"mutter: joining an existing display group — extending (the first session owns the \
|
||||
exclusive/primary topology)"
|
||||
);
|
||||
}
|
||||
let exclusive = matches!(topo, Topology::Exclusive);
|
||||
// Snapshot the monitor layout BEFORE the virtual output exists — it's how we tell the new
|
||||
// connector apart, both for the topology apply and for tracking the scale the user sets on
|
||||
// it. Taken unconditionally now (scale tracking wants it even when we won't touch the
|
||||
// topology); failure just degrades to no-topology + no-scale-persistence, as before.
|
||||
let dc_pre = match display_config().await {
|
||||
Ok(dc) => match get_state(&dc).await {
|
||||
Ok(state) => Some((dc, state)),
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "mutter: GetCurrentState (pre) failed; topology + scale persistence off");
|
||||
None
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %format!("{e:#}"), "mutter: DisplayConfig unavailable; topology + scale persistence off");
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
let session = match connect(mode, remembered_scale).await {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("{e:#}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let _ = setup_tx.send(Ok(session.node_id));
|
||||
|
||||
// Identify the virtual connector (present now, absent in the pre-snapshot), then — when this
|
||||
// session owns the topology — make it the PRIMARY monitor so the GNOME shell + new windows
|
||||
// land on the surface we stream. Without this, on a host that also has a physical monitor
|
||||
// attached, the virtual output is an empty extended desktop — you stream only the wallpaper.
|
||||
// Best-effort: any failure just logs and streaming continues unchanged.
|
||||
let mut tracked: Option<(zbus::Proxy<'static>, CurrentState, String)> = None;
|
||||
if let Some((dc, pre)) = dc_pre {
|
||||
match wait_virtual_connector(&dc, &pre).await {
|
||||
Ok((vconn, state)) => {
|
||||
if want_config {
|
||||
match make_virtual_primary(
|
||||
&dc,
|
||||
mode,
|
||||
&pre,
|
||||
&state,
|
||||
&vconn,
|
||||
exclusive,
|
||||
remembered_scale,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => tracing::info!(
|
||||
exclusive,
|
||||
"mutter: virtual output set as the primary monitor (physicals {})",
|
||||
if exclusive { "disabled" } else { "kept" }
|
||||
),
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"mutter: could not set the virtual output primary; streaming continues — the desktop may render on the physical monitor"
|
||||
),
|
||||
}
|
||||
}
|
||||
tracked = Some((dc, pre, vconn));
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"mutter: virtual connector not identified; topology + scale persistence off"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
drop(topology_guard);
|
||||
|
||||
// Park, keeping `session` (and its zbus connection) alive until told to stop. Every ~5 s,
|
||||
// read the virtual output's logical-monitor scale and persist a change the user made (GNOME
|
||||
// Settings mid-stream) under the client's key — polled rather than teardown-only so a host
|
||||
// crash/redeploy doesn't lose it.
|
||||
let mut known = remembered_scale.unwrap_or(1.0);
|
||||
let mut ticks: u32 = 0;
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
ticks = ticks.wrapping_add(1);
|
||||
if ticks % 25 == 0 {
|
||||
if let Some((dc, _, vconn)) = &tracked {
|
||||
persist_scale_change(dc, vconn, &scale_key, &mut known).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Final scale read BEFORE Stop (the virtual output must still exist to be read).
|
||||
if let Some((dc, _, vconn)) = &tracked {
|
||||
persist_scale_change(dc, vconn, &scale_key, &mut known).await;
|
||||
}
|
||||
|
||||
// Tear down: STOP the screencast so Mutter removes the virtual output. We deliberately do NOT
|
||||
// re-assert the physical layout with our own ApplyMonitorsConfig. Issuing a monitor reconfig
|
||||
// while the just-removed high-refresh virtual output is still tearing down SIGSEGVs gnome-shell
|
||||
// on Mutter 50 + NVIDIA — observed live on home-worker-3: the teardown ApplyMonitorsConfig
|
||||
// returned "recipient disconnected from message bus" because the shell crashed mid-call, after
|
||||
// which GDM's crash-loop guard dropped to the greeter and wedged EVERY subsequent reconnect.
|
||||
// make_virtual_primary applied an APPLY_TEMPORARY config; Mutter reverts that on its own once
|
||||
// the virtual output disappears and our DisplayConfig connection (in `tracked`) closes — so we
|
||||
// just drop it here and let the revert happen Mutter-side, never touching the layout ourselves.
|
||||
// The Stop (+ the revert it triggers) is a topology mutation too — take TOPOLOGY_LOCK so a
|
||||
// sibling's teardown or setup can't interleave with the rebuild it causes.
|
||||
let _topology_guard = TOPOLOGY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = session.rd_session.call_method("Stop", &()).await;
|
||||
drop(tracked);
|
||||
});
|
||||
}
|
||||
|
||||
/// 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). `preferred_scale` is the client's remembered
|
||||
/// desktop scale, passed as the virtual mode's `preferred-scale` so Mutter creates the monitor
|
||||
/// already scaled (Mutter ≥ 48; older Mutter ignores unknown mode keys) — this covers the
|
||||
/// `extend` topology, where we never issue our own ApplyMonitorsConfig.
|
||||
async fn connect(mode: Mode, preferred_scale: Option<f64>) -> 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. For >60 Hz we pin the client's exact WxH@Hz via RecordVirtual's
|
||||
// "modes" (explicit size + refresh-rate; Mutter ≥ 47) — validated at 5120×1440@240 on Mutter 50
|
||||
// + NVIDIA. At ≤60 Hz we let Mutter derive the refresh from the PipeWire framerate (its 60 Hz
|
||||
// default is already correct), so the custom-mode path only runs when it buys something.
|
||||
// (A high-refresh virtual CRTC used to SIGSEGV gnome-shell on teardown, which is why this was
|
||||
// once gated behind PUNKTFUNK_MUTTER_VIRTUAL_REFRESH; the stop-screencast-before-any-monitor-
|
||||
// reconfig teardown below fixed the crash, so pinning the client's refresh is now the default.)
|
||||
let mut rec: HashMap<&str, Value> = HashMap::new();
|
||||
rec.insert("cursor-mode", Value::from(CURSOR_EMBEDDED));
|
||||
if mode.refresh_hz > 60 || preferred_scale.is_some() {
|
||||
let mut vmode: HashMap<&str, Value> = HashMap::new();
|
||||
vmode.insert("size", Value::from((mode.width, mode.height)));
|
||||
// Only pin the refresh when it buys something (see above) — a remembered scale alone
|
||||
// rides Mutter's 60 Hz default, exactly like the no-modes path did.
|
||||
if mode.refresh_hz > 60 {
|
||||
vmode.insert("refresh-rate", Value::from(mode.refresh_hz as f64));
|
||||
}
|
||||
if let Some(scale) = preferred_scale {
|
||||
vmode.insert("preferred-scale", Value::from(scale));
|
||||
}
|
||||
vmode.insert("is-preferred", Value::from(true));
|
||||
rec.insert("modes", Value::from(vec![vmode]));
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Optional: make the per-session virtual output the PRIMARY monitor (PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY).
|
||||
//
|
||||
// `RecordVirtual` adds the virtual monitor as an *extended* desktop. On a headless host that's the
|
||||
// only display, so the shell + windows live there. But when a physical monitor is attached, GNOME
|
||||
// keeps it primary and the virtual output is an empty extension — the stream shows only the
|
||||
// wallpaper. We fix that by promoting the virtual output to primary (physical kept on, secondary)
|
||||
// via `org.gnome.Mutter.DisplayConfig.ApplyMonitorsConfig`, and restore on teardown.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// `org.gnome.Mutter.DisplayConfig.GetCurrentState` reply shapes (see the interface XML):
|
||||
/// monitors: `a((ssss)a(siiddada{sv})a{sv})`
|
||||
/// logical_monitors: `a(iiduba(ssss)a{sv})`
|
||||
type MonitorSpec = (String, String, String, String); // connector, vendor, product, serial
|
||||
type DbusMode = (
|
||||
String,
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
f64,
|
||||
Vec<f64>,
|
||||
HashMap<String, OwnedValue>,
|
||||
);
|
||||
type MonitorInfo = (MonitorSpec, Vec<DbusMode>, HashMap<String, OwnedValue>);
|
||||
type LogicalMonitor = (
|
||||
i32,
|
||||
i32,
|
||||
f64,
|
||||
u32,
|
||||
bool,
|
||||
Vec<MonitorSpec>,
|
||||
HashMap<String, OwnedValue>,
|
||||
);
|
||||
type CurrentState = (
|
||||
u32,
|
||||
Vec<MonitorInfo>,
|
||||
Vec<LogicalMonitor>,
|
||||
HashMap<String, OwnedValue>,
|
||||
);
|
||||
|
||||
/// `ApplyMonitorsConfig` logical-monitor shape: `(iiduba(ssa{sv}))`, monitor = `(ssa{sv})`.
|
||||
type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
|
||||
type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>);
|
||||
|
||||
/// A DisplayConfig proxy on its own session-bus connection (owned, so it stays alive for the
|
||||
/// session — independent of the RemoteDesktop/ScreenCast connection).
|
||||
async fn display_config() -> Result<zbus::Proxy<'static>> {
|
||||
let conn = zbus::Connection::session()
|
||||
.await
|
||||
.context("connect session D-Bus (DisplayConfig)")?;
|
||||
zbus::Proxy::new(
|
||||
&conn,
|
||||
BUS_DC,
|
||||
"/org/gnome/Mutter/DisplayConfig",
|
||||
"org.gnome.Mutter.DisplayConfig",
|
||||
)
|
||||
.await
|
||||
.context("DisplayConfig proxy")
|
||||
}
|
||||
|
||||
async fn get_state(dc: &zbus::Proxy<'_>) -> Result<CurrentState> {
|
||||
dc.call("GetCurrentState", &())
|
||||
.await
|
||||
.context("DisplayConfig.GetCurrentState")
|
||||
}
|
||||
|
||||
fn connectors(state: &CurrentState) -> HashSet<String> {
|
||||
state.1.iter().map(|m| m.0 .0.clone()).collect()
|
||||
}
|
||||
|
||||
fn mode_flag(md: &DbusMode, key: &str) -> bool {
|
||||
matches!(md.6.get(key).map(|v| &**v), Some(&Value::Bool(true)))
|
||||
}
|
||||
|
||||
/// The current (else preferred, else first) mode of `connector` → `(mode_id, width, height, refresh)`.
|
||||
fn current_mode_full(state: &CurrentState, connector: &str) -> Option<(String, i32, i32, f64)> {
|
||||
let mon = state.1.iter().find(|m| m.0 .0 == connector)?;
|
||||
let pick = mon
|
||||
.1
|
||||
.iter()
|
||||
.find(|md| mode_flag(md, "is-current"))
|
||||
.or_else(|| mon.1.iter().find(|md| mode_flag(md, "is-preferred")))
|
||||
.or_else(|| mon.1.first())?;
|
||||
Some((pick.0.clone(), pick.1, pick.2, pick.3))
|
||||
}
|
||||
|
||||
/// As [`current_mode_full`] but dropping the refresh (callers that only place by width).
|
||||
fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i32)> {
|
||||
current_mode_full(state, connector).map(|(id, w, h, _)| (id, w, h))
|
||||
}
|
||||
|
||||
/// Pure mode-pick for a KEPT physical (unit-tested). Given the physical's PRE-connect mode
|
||||
/// (`pre_mode = (id, w, h, refresh)`; `None` when the connector is new since the snapshot) and the
|
||||
/// mode list Mutter reports for it in the POST-virtual state
|
||||
/// (`(id, w, h, refresh, is_current, is_preferred)`), return the `(mode_id, width)` to re-apply.
|
||||
///
|
||||
/// Mutter re-derives its layout when the `RecordVirtual` output appears and can silently drop a
|
||||
/// 120 Hz panel to its EDID-preferred 60 Hz — so the post-virtual `is-current` is *already* 60 Hz.
|
||||
/// We therefore prefer the PRE mode (its real refresh), resolved to a mode id valid at apply time;
|
||||
/// only when the physical genuinely no longer offers that mode do we fall back to the post-virtual
|
||||
/// current (never inventing a mode id `ApplyMonitorsConfig` would reject).
|
||||
fn pick_keep_mode(
|
||||
pre_mode: Option<(String, i32, i32, f64)>,
|
||||
state_modes: &[(String, i32, i32, f64, bool, bool)],
|
||||
) -> Option<(String, i32)> {
|
||||
let state_current = || {
|
||||
state_modes
|
||||
.iter()
|
||||
.find(|m| m.4)
|
||||
.or_else(|| state_modes.iter().find(|m| m.5))
|
||||
.or_else(|| state_modes.first())
|
||||
.map(|m| (m.0.clone(), m.1))
|
||||
};
|
||||
let Some((pre_id, w, h, hz)) = pre_mode else {
|
||||
return state_current();
|
||||
};
|
||||
// The exact pre mode id, if the connector still offers it (same session ⇒ usually true).
|
||||
if state_modes.iter().any(|m| m.0 == pre_id) {
|
||||
return Some((pre_id, w));
|
||||
}
|
||||
// Else a re-keyed id with the same geometry + refresh (still the real 120 Hz).
|
||||
if let Some(m) = state_modes
|
||||
.iter()
|
||||
.find(|m| m.1 == w && m.2 == h && (m.3 - hz).abs() < 0.5)
|
||||
{
|
||||
return Some((m.0.clone(), m.1));
|
||||
}
|
||||
// The physical genuinely no longer offers that mode — use whatever is valid now.
|
||||
state_current()
|
||||
}
|
||||
|
||||
/// The `(mode_id, width)` a kept physical should be RE-APPLIED at — its PRE-connect mode preserved
|
||||
/// across Mutter's virtual-output layout re-derive. See [`pick_keep_mode`].
|
||||
fn physical_keep_mode(
|
||||
pre: &CurrentState,
|
||||
state: &CurrentState,
|
||||
conn: &str,
|
||||
) -> Option<(String, i32)> {
|
||||
let pre_mode = current_mode_full(pre, conn);
|
||||
let state_modes: Vec<(String, i32, i32, f64, bool, bool)> = state
|
||||
.1
|
||||
.iter()
|
||||
.find(|m| m.0 .0 == conn)
|
||||
.map(|mon| {
|
||||
mon.1
|
||||
.iter()
|
||||
.map(|md| {
|
||||
(
|
||||
md.0.clone(),
|
||||
md.1,
|
||||
md.2,
|
||||
md.3,
|
||||
mode_flag(md, "is-current"),
|
||||
mode_flag(md, "is-preferred"),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
pick_keep_mode(pre_mode, &state_modes)
|
||||
}
|
||||
|
||||
/// Wait for the virtual output to appear in DisplayConfig (its size follows PipeWire negotiation,
|
||||
/// which lands shortly after the node id) and return its connector name (present now, absent in
|
||||
/// the pre-snapshot) plus the state that contained it.
|
||||
async fn wait_virtual_connector(
|
||||
dc: &zbus::Proxy<'_>,
|
||||
pre: &CurrentState,
|
||||
) -> Result<(String, CurrentState)> {
|
||||
let pre_conns = connectors(pre);
|
||||
let deadline = Instant::now() + Duration::from_secs(6);
|
||||
loop {
|
||||
let state = get_state(dc).await?;
|
||||
let virt = state
|
||||
.1
|
||||
.iter()
|
||||
.map(|m| m.0 .0.clone())
|
||||
.find(|c| !pre_conns.contains(c));
|
||||
if let Some(vconn) = virt {
|
||||
return Ok((vconn, state));
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!("the virtual monitor did not appear in DisplayConfig within 6s");
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Make the virtual output the primary output — SOLE (`exclusive`: physicals disabled for the
|
||||
/// session) or with the physicals kept as secondaries — so the cursor, windows, and keyboard focus
|
||||
/// stay on the streamed surface. Applied at the client's `remembered_scale` (validated against the
|
||||
/// mode's supported scales; 1.0 when none is remembered) so a saved DPI setting survives the
|
||||
/// reconnect. Reverted by Mutter on teardown (APPLY_TEMPORARY).
|
||||
async fn make_virtual_primary(
|
||||
dc: &zbus::Proxy<'_>,
|
||||
mode: Mode,
|
||||
pre: &CurrentState,
|
||||
state: &CurrentState,
|
||||
vconn: &str,
|
||||
exclusive: bool,
|
||||
remembered_scale: Option<f64>,
|
||||
) -> Result<()> {
|
||||
// Prefer the mode matching the client's WxH; fall back to whatever is current.
|
||||
let vmode = state
|
||||
.1
|
||||
.iter()
|
||||
.find(|m| m.0 .0 == vconn)
|
||||
.and_then(|m| {
|
||||
m.1.iter()
|
||||
.find(|md| md.1 == mode.width as i32 && md.2 == mode.height as i32)
|
||||
.map(|md| md.0.clone())
|
||||
})
|
||||
.or_else(|| current_mode(state, vconn).map(|(id, _, _)| id));
|
||||
let Some(vmode) = vmode else {
|
||||
bail!("virtual monitor {vconn} has no usable mode yet");
|
||||
};
|
||||
// The scale to apply. Mutter (≥ its `preferred-scale` support) already derived the virtual's
|
||||
// logical monitor at the remembered scale we passed to RecordVirtual, PRE-VALIDATED — preserve
|
||||
// that instead of forcing a value (forcing 1.0 here was the original scale-clobber bug). On an
|
||||
// older Mutter the derived scale stays 1.0 while a scale is remembered — try the remembered
|
||||
// value snapped to an integral logical size (Mutter's fractional-scaling validity rule;
|
||||
// GetCurrentState reports NO supported-scales for virtual monitors to snap to), and retry at
|
||||
// the derived scale if the whole apply is rejected (an invalid scale fails the entire config —
|
||||
// losing the primary switch over scaling would be worse).
|
||||
let derived = logical_scale(state, vconn)
|
||||
.filter(|s| s.is_finite() && *s > 0.0)
|
||||
.unwrap_or(1.0);
|
||||
let mut scale = match remembered_scale {
|
||||
Some(want) if (want - derived).abs() > 1e-3 => {
|
||||
snap_integral_scale(want, mode.width, mode.height)
|
||||
}
|
||||
_ => derived,
|
||||
};
|
||||
loop {
|
||||
// Exclusive: the virtual output alone (physicals omitted → Mutter disables them).
|
||||
// Primary: the virtual output primary at (0,0) PLUS the physicals kept as secondaries.
|
||||
// (On a headless host with no physicals the two are identical.)
|
||||
let config = if exclusive {
|
||||
build_exclusive_config(vconn, &vmode, scale)
|
||||
} else {
|
||||
build_primary_keeping_physicals(pre, state, vconn, &vmode, mode.width as i32, scale)
|
||||
};
|
||||
let res: zbus::Result<()> = dc
|
||||
.call(
|
||||
"ApplyMonitorsConfig",
|
||||
&(
|
||||
state.0,
|
||||
APPLY_TEMPORARY,
|
||||
config,
|
||||
HashMap::<String, Value<'static>>::new(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
match res {
|
||||
Ok(()) => return Ok(()),
|
||||
Err(e) if (scale - derived).abs() > 1e-3 => {
|
||||
tracing::warn!(
|
||||
scale,
|
||||
derived,
|
||||
error = %format!("{e:#}"),
|
||||
"mutter: ApplyMonitorsConfig at the remembered scale failed — retrying at the derived scale"
|
||||
);
|
||||
scale = derived;
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(e).context("DisplayConfig.ApplyMonitorsConfig (set virtual primary)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Snap `want` to the nearest scale that gives the mode an **integral logical size** — Mutter only
|
||||
/// accepts fractional scales where both `width/scale` and `height/scale` are integers, and its
|
||||
/// GetCurrentState reports no `supported-scales` for virtual monitors to snap to. Searches the few
|
||||
/// logical widths around the target for one that keeps the aspect exact; falls back to `want`
|
||||
/// unchanged (the caller retries at the derived scale if Mutter still rejects it). Pure, unit-tested.
|
||||
fn snap_integral_scale(want: f64, width: u32, height: u32) -> f64 {
|
||||
if !want.is_finite() || want <= 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let (w, h) = (width as i64, height as i64);
|
||||
let target = (w as f64 / want).round() as i64;
|
||||
(target - 8..=target + 8)
|
||||
.filter(|lw| *lw >= 1 && (h * lw) % w == 0)
|
||||
.map(|lw| w as f64 / lw as f64)
|
||||
.min_by(|a, b| (a - want).abs().total_cmp(&(b - want).abs()))
|
||||
.unwrap_or(want)
|
||||
}
|
||||
|
||||
/// The scale of the logical monitor carrying `connector`, if present.
|
||||
fn logical_scale(state: &CurrentState, connector: &str) -> Option<f64> {
|
||||
state
|
||||
.2
|
||||
.iter()
|
||||
.find(|l| l.5.iter().any(|spec| spec.0 == connector))
|
||||
.map(|l| l.2)
|
||||
}
|
||||
|
||||
/// Read the virtual output's current scale and, when the user changed it (GNOME Settings
|
||||
/// mid-stream), persist it under the client's `scale_key` so the next connect reapplies it.
|
||||
/// Best-effort: read failures (teardown races, shell restart) are silently skipped.
|
||||
async fn persist_scale_change(dc: &zbus::Proxy<'_>, vconn: &str, scale_key: &str, known: &mut f64) {
|
||||
let Ok(state) = get_state(dc).await else {
|
||||
return;
|
||||
};
|
||||
let Some(cur) = logical_scale(&state, vconn) else {
|
||||
return;
|
||||
};
|
||||
if (cur - *known).abs() > 1e-3 {
|
||||
crate::identity::scales()
|
||||
.lock()
|
||||
.unwrap()
|
||||
.set(scale_key, cur);
|
||||
*known = cur;
|
||||
tracing::info!(
|
||||
scale = cur,
|
||||
"mutter: persisted the client's display scale for the next connect"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// **Exclusive** — the virtual output as the SOLE, primary monitor: physical outputs are omitted, so
|
||||
/// Mutter disables them for the session. This confines the cursor, windows, and keyboard focus to the
|
||||
/// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative
|
||||
/// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
|
||||
/// vanish). The physical layout is restored on teardown.
|
||||
fn build_exclusive_config(vconn: &str, vmode: &str, scale: f64) -> Vec<ApplyLogical> {
|
||||
vec![(
|
||||
0,
|
||||
0,
|
||||
scale,
|
||||
0,
|
||||
true,
|
||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||
)]
|
||||
}
|
||||
|
||||
/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical
|
||||
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its **pre-connect**
|
||||
/// mode). So the shell + new windows land on the streamed surface, but the operator's physical
|
||||
/// screen stays on **at its real refresh**. On a headless host (no physicals) this is identical to
|
||||
/// [`build_exclusive_config`].
|
||||
///
|
||||
/// `pre` is the snapshot taken *before* the virtual output existed (physical still at its true
|
||||
/// refresh); `state` is the post-virtual state. We read each physical's mode from `pre` because
|
||||
/// Mutter can knock a 120 Hz panel down to 60 Hz when it re-derives the layout for the virtual
|
||||
/// monitor — reading `state` would cement that 60 Hz (`physical_keep_mode`).
|
||||
///
|
||||
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
|
||||
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
|
||||
fn build_primary_keeping_physicals(
|
||||
pre: &CurrentState,
|
||||
state: &CurrentState,
|
||||
vconn: &str,
|
||||
vmode: &str,
|
||||
virt_width: i32,
|
||||
scale: f64,
|
||||
) -> Vec<ApplyLogical> {
|
||||
let mut logicals: Vec<ApplyLogical> = vec![(
|
||||
0,
|
||||
0,
|
||||
scale,
|
||||
0,
|
||||
true,
|
||||
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
|
||||
)];
|
||||
// Append each physical (non-virtual) connector that has a usable mode, to the right of the
|
||||
// virtual output, as a non-primary secondary — at its PRE-connect mode (real refresh preserved).
|
||||
// Offsets are in the layout's coordinate space: LOGICAL pixels by default on Wayland (the
|
||||
// virtual's footprint is width/scale), physical pixels only under layout-mode 2.
|
||||
let physical_layout = matches!(
|
||||
state.3.get("layout-mode").map(|v| &**v),
|
||||
Some(&Value::U32(2))
|
||||
);
|
||||
let virt_logical_width = if physical_layout {
|
||||
virt_width
|
||||
} else {
|
||||
((virt_width as f64 / scale).round() as i32).max(1)
|
||||
};
|
||||
let mut x = virt_logical_width.max(0);
|
||||
for mon in &state.1 {
|
||||
let conn = &mon.0 .0;
|
||||
if conn == vconn {
|
||||
continue;
|
||||
}
|
||||
if let Some((mode_id, w)) = physical_keep_mode(pre, state, conn) {
|
||||
logicals.push((
|
||||
x,
|
||||
0,
|
||||
1.0,
|
||||
0,
|
||||
false,
|
||||
vec![(conn.clone(), mode_id, HashMap::new())],
|
||||
));
|
||||
x += w.max(0);
|
||||
}
|
||||
}
|
||||
logicals
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{pick_keep_mode, snap_integral_scale};
|
||||
|
||||
// (id, w, h, refresh, is_current, is_preferred)
|
||||
fn m(
|
||||
id: &str,
|
||||
w: i32,
|
||||
h: i32,
|
||||
hz: f64,
|
||||
cur: bool,
|
||||
pref: bool,
|
||||
) -> (String, i32, i32, f64, bool, bool) {
|
||||
(id.to_string(), w, h, hz, cur, pref)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_mode_prefers_pre_refresh_over_downgraded_state() {
|
||||
// Physical was 2560x1440@120 pre-connect; after the virtual appeared Mutter marked 60 Hz
|
||||
// current (the reported bug). We must re-apply the 120 Hz mode, not the state's 60 Hz.
|
||||
let pre = Some(("M120".to_string(), 2560, 1440, 120.0));
|
||||
let state = vec![
|
||||
m("M120", 2560, 1440, 120.0, false, false),
|
||||
m("M60", 2560, 1440, 60.0, true, true),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(pre, &state),
|
||||
Some(("M120".to_string(), 2560))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_mode_rekeyed_id_matches_by_geometry_and_refresh() {
|
||||
// The pre id is no longer offered (Mutter re-keyed the mode list), but a 120 Hz mode of the
|
||||
// same geometry exists — match it so the real refresh survives.
|
||||
let pre = Some(("old-120".to_string(), 2560, 1440, 120.0));
|
||||
let state = vec![
|
||||
m("new-120", 2560, 1440, 119.998, false, false),
|
||||
m("new-60", 2560, 1440, 60.0, true, true),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(pre, &state),
|
||||
Some(("new-120".to_string(), 2560))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_mode_falls_back_to_state_current_when_pre_mode_gone() {
|
||||
// The physical genuinely no longer offers its pre mode (e.g. cable renegotiated to a lower
|
||||
// max) — never invent an id; use the post-virtual current.
|
||||
let pre = Some(("gone-165".to_string(), 3440, 1440, 165.0));
|
||||
let state = vec![
|
||||
m("s-100", 3440, 1440, 100.0, true, false),
|
||||
m("s-60", 3440, 1440, 60.0, false, true),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(pre, &state),
|
||||
Some(("s-100".to_string(), 3440))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snap_integral_scale_keeps_valid_scales_and_snaps_odd_ones() {
|
||||
// Already-integral scales survive exactly: 1920/1.5 = 1280, 1080/1.5 = 720.
|
||||
assert_eq!(snap_integral_scale(1.5, 1920, 1080), 1.5);
|
||||
// The GNOME fractional 1.6666… on 3840x2400 (logical 2304x1440) survives.
|
||||
let s = snap_integral_scale(1.666_666_6, 3840, 2400);
|
||||
assert!((s - 3840.0 / 2304.0).abs() < 1e-9, "got {s}");
|
||||
// A scale with no integral logical size nearby snaps to the closest one that has it:
|
||||
// 16:9 logical widths must be multiples of 16 → 1.3 snaps to 1920/1472.
|
||||
let s = snap_integral_scale(1.3, 1920, 1080);
|
||||
assert!((s - 1920.0 / 1472.0).abs() < 1e-9, "got {s}");
|
||||
// Junk input degrades to 1.0.
|
||||
assert_eq!(snap_integral_scale(f64::NAN, 1920, 1080), 1.0);
|
||||
assert_eq!(snap_integral_scale(-2.0, 1920, 1080), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keep_mode_no_pre_uses_state_current_then_preferred() {
|
||||
// A connector new since the pre-snapshot (no pre mode): is-current wins, else is-preferred.
|
||||
let state = vec![
|
||||
m("A", 1920, 1080, 60.0, true, false),
|
||||
m("B", 1920, 1080, 144.0, false, true),
|
||||
];
|
||||
assert_eq!(pick_keep_mode(None, &state), Some(("A".to_string(), 1920)));
|
||||
|
||||
let no_current = vec![
|
||||
m("A", 1920, 1080, 60.0, false, false),
|
||||
m("B", 1920, 1080, 144.0, false, true),
|
||||
];
|
||||
assert_eq!(
|
||||
pick_keep_mode(None, &no_current),
|
||||
Some(("B".to_string(), 1920))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
//! wlroots/Sway virtual-output backend via sway IPC + the xdg ScreenCast portal
|
||||
//! (xdg-desktop-portal-wlr):
|
||||
//!
|
||||
//! 1. `swaymsg create_output` adds a headless output (`HEADLESS-N` — sway must run the
|
||||
//! headless backend, or have it co-loaded; the name is found by diffing
|
||||
//! `swaymsg -t get_outputs` before/after).
|
||||
//! 2. `swaymsg output <NAME> mode --custom WxH@HzHz` sets the client's exact mode — a fresh
|
||||
//! headless output also *needs* a real mode for a refresh clock, or it produces no frames.
|
||||
//! 3. The ScreenCast portal yields the output's PipeWire node. There is no GUI to pick an
|
||||
//! output headlessly, so xdpw is steered through its chooser hook: a managed config
|
||||
//! (`~/.config/xdg-desktop-portal-wlr/config`, written once + portal restarted on change)
|
||||
//! sets `chooser_type=simple` with a `chooser_cmd` that cats the chooser file, which we
|
||||
//! write per session (`Monitor: <NAME>` — xdpw 0.8 parses that prefix strictly).
|
||||
//! 4. Teardown is RAII: drop stops the portal thread (its zbus connection ends the cast) and
|
||||
//! runs `swaymsg output <NAME> unplug` (headless outputs support unplug since sway 1.8).
|
||||
//!
|
||||
//! Requirements: the host runs inside the sway session's environment (`SWAYSOCK` for swaymsg,
|
||||
//! and the portal activation env — `WAYLAND_DISPLAY`/`XDG_CURRENT_DESKTOP=sway` imported into
|
||||
//! `systemctl --user`, see `scripts/headless/prepare-session.sh`), with the ScreenCast
|
||||
//! interface routed to xdpw (`scripts/headless/portals.conf`).
|
||||
|
||||
use super::{DisplayOwnership, Mode, VirtualDisplay, VirtualOutput};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::mpsc::Sender;
|
||||
use std::sync::Arc;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// File the xdpw output chooser reads the selected output from (see [`xdpw_config`]); we
|
||||
/// write `Monitor: <NAME>\n` here right before the portal handshake selects sources. Lives
|
||||
/// under `$XDG_RUNTIME_DIR` (per-user, mode 0700) — NOT a fixed world-writable /tmp path,
|
||||
/// where another local user could pre-create it (DoS) or rewrite it between our write and
|
||||
/// xdpw's read (steer capture at a different output).
|
||||
fn chooser_file() -> String {
|
||||
let dir = std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".into());
|
||||
format!("{dir}/punktfunk-xdpw-output")
|
||||
}
|
||||
|
||||
/// The managed xdpw config: per-session output selection with no GUI. The `|| echo` fallback
|
||||
/// keeps plain portal capture (`--source portal` flow) working when no session has written
|
||||
/// the chooser file. xdpw runs `chooser_cmd` via `/bin/sh -c`, reads stdout.
|
||||
fn xdpw_config() -> String {
|
||||
format!(
|
||||
"# managed by punktfunk (vdisplay/wlroots.rs) — per-session output selection.\n\
|
||||
[screencast]\n\
|
||||
chooser_type=simple\n\
|
||||
chooser_cmd=cat {} 2>/dev/null || echo 'Monitor: HEADLESS-1'\n",
|
||||
chooser_file()
|
||||
)
|
||||
}
|
||||
|
||||
/// The wlroots/Sway virtual-display driver. Stateless — each [`create`](VirtualDisplay::create)
|
||||
/// adds one headless output and spins up a portal thread owning the cast on it.
|
||||
pub struct WlrootsDisplay;
|
||||
|
||||
impl WlrootsDisplay {
|
||||
pub fn new() -> Result<Self> {
|
||||
Ok(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"
|
||||
}
|
||||
|
||||
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
|
||||
let before = output_names()
|
||||
.context("swaymsg get_outputs (is the host inside the sway session env — SWAYSOCK?)")?;
|
||||
swaymsg(&["create_output"])
|
||||
.context("swaymsg create_output (sway needs the headless backend loaded)")?;
|
||||
// The output appears synchronously in practice; poll briefly to be safe, and own it
|
||||
// from here on so error unwinding unplugs it.
|
||||
let output = OutputGuard(wait_new_output(&before, Duration::from_secs(5))?);
|
||||
let name = output.0.clone();
|
||||
|
||||
// The client's exact mode (also the refresh clock that makes the output produce frames).
|
||||
let m = format!(
|
||||
"{}x{}@{}Hz",
|
||||
mode.width,
|
||||
mode.height,
|
||||
mode.refresh_hz.max(1)
|
||||
);
|
||||
swaymsg(&["output", &name, "mode", "--custom", &m])
|
||||
.with_context(|| format!("swaymsg output {name} mode --custom {m}"))?;
|
||||
swaymsg(&["output", &name, "enable"])
|
||||
.with_context(|| format!("swaymsg output {name} enable"))?;
|
||||
|
||||
// Steer xdpw's headless output chooser at our new output, then run the portal
|
||||
// handshake on its own thread (it parks to keep the cast alive, like the other backends).
|
||||
ensure_xdpw_config()?;
|
||||
let chooser = chooser_file();
|
||||
std::fs::write(&chooser, format!("Monitor: {name}\n"))
|
||||
.with_context(|| format!("write {chooser}"))?;
|
||||
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<(OwnedFd, u32), String>>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let stop_thread = stop.clone();
|
||||
thread::Builder::new()
|
||||
.name("punktfunk-wlr-vout".into())
|
||||
.spawn(move || portal_thread(setup_tx, stop_thread))
|
||||
.context("spawn wlroots portal thread")?;
|
||||
|
||||
let (fd, node_id) = match setup_rx.recv_timeout(Duration::from_secs(20)) {
|
||||
Ok(Ok(v)) => v,
|
||||
Ok(Err(e)) => bail!("ScreenCast portal on {name} failed: {e}"),
|
||||
Err(_) => bail!("timed out waiting for the ScreenCast portal on {name}"),
|
||||
};
|
||||
tracing::info!(
|
||||
node_id,
|
||||
output = %name,
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
hz = mode.refresh_hz,
|
||||
"sway headless output ready"
|
||||
);
|
||||
Ok(VirtualOutput {
|
||||
node_id,
|
||||
remote_fd: Some(fd),
|
||||
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
|
||||
keepalive: Box::new(Keepalive {
|
||||
_stop: StopGuard(stop),
|
||||
_output: output,
|
||||
}),
|
||||
// Owned (the compositor output is ours to tear down), but not registry-poolable: the
|
||||
// portal fd can't be re-opened per attach, so the registry passes it through on
|
||||
// `remote_fd.is_some()` (keep-alive stays off for wlroots until fresh-portal re-attach).
|
||||
ownership: DisplayOwnership::Owned,
|
||||
reused_gen: None,
|
||||
pool_gen: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop order matters: stop the portal thread first (zbus connection drop ends the cast),
|
||||
/// then unplug the output (fields drop in declaration order).
|
||||
struct Keepalive {
|
||||
_stop: StopGuard,
|
||||
_output: OutputGuard,
|
||||
}
|
||||
|
||||
/// Dropping this ends the portal keepalive thread, closing its zbus connection — the portal
|
||||
/// then tears the screencast session down.
|
||||
struct StopGuard(Arc<AtomicBool>);
|
||||
|
||||
impl Drop for StopGuard {
|
||||
fn drop(&mut self) {
|
||||
self.0.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the created headless output; dropping it unplugs it from sway.
|
||||
struct OutputGuard(String);
|
||||
|
||||
impl Drop for OutputGuard {
|
||||
fn drop(&mut self) {
|
||||
match swaymsg(&["output", &self.0, "unplug"]) {
|
||||
Ok(_) => tracing::info!(output = %self.0, "sway headless output unplugged"),
|
||||
Err(e) => tracing::warn!(output = %self.0, error = %format!("{e:#}"), "unplug failed"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `swaymsg -- <args>`, returning stdout (`--` so command tokens like `--custom` reach
|
||||
/// sway instead of swaymsg's own getopt). swaymsg exits non-zero (with the error on stderr/
|
||||
/// stdout) when the command fails, so checking the status covers `{"success": false}` too.
|
||||
fn swaymsg(args: &[&str]) -> Result<String> {
|
||||
let out = Command::new("swaymsg")
|
||||
.arg("--")
|
||||
.args(args)
|
||||
.output()
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"swaymsg {:?} failed: {}{}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stdout).trim(),
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
}
|
||||
|
||||
/// Current output names from `swaymsg -t get_outputs` (JSON).
|
||||
fn output_names() -> Result<Vec<String>> {
|
||||
let out = Command::new("swaymsg")
|
||||
.args(["-t", "get_outputs", "--raw"])
|
||||
.output()
|
||||
.context("run swaymsg (is sway installed?)")?;
|
||||
if !out.status.success() {
|
||||
bail!(
|
||||
"swaymsg -t get_outputs failed: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
);
|
||||
}
|
||||
let raw = String::from_utf8_lossy(&out.stdout).into_owned();
|
||||
let outputs: serde_json::Value = serde_json::from_str(&raw).context("parse get_outputs")?;
|
||||
Ok(outputs
|
||||
.as_array()
|
||||
.context("get_outputs: not an array")?
|
||||
.iter()
|
||||
.filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(str::to_owned))
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Wait for the output `create_output` added (the name not in `before` — HEADLESS-N).
|
||||
fn wait_new_output(before: &[String], timeout: Duration) -> Result<String> {
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
if let Some(name) = output_names()?
|
||||
.into_iter()
|
||||
.find(|n| !before.iter().any(|b| b == n))
|
||||
{
|
||||
return Ok(name);
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
bail!("create_output succeeded but no new output appeared");
|
||||
}
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
}
|
||||
|
||||
/// Make sure xdpw uses our output chooser. xdpw reads its config only at startup, so on a
|
||||
/// change restart it if running (`try-restart`; if it isn't, D-Bus activation will start it
|
||||
/// with the new config). The config itself is static — the *selection* is the chooser file.
|
||||
fn ensure_xdpw_config() -> Result<()> {
|
||||
let base = std::env::var_os("XDG_CONFIG_HOME")
|
||||
.map(std::path::PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".config")))
|
||||
.ok_or_else(|| anyhow!("neither XDG_CONFIG_HOME nor HOME set"))?;
|
||||
let dir = base.join("xdg-desktop-portal-wlr");
|
||||
let path = dir.join("config");
|
||||
let cfg = xdpw_config();
|
||||
if std::fs::read_to_string(&path).is_ok_and(|c| c == cfg) {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(&dir).with_context(|| format!("mkdir {}", dir.display()))?;
|
||||
std::fs::write(&path, &cfg).with_context(|| format!("write {}", path.display()))?;
|
||||
tracing::info!(path = %path.display(), "wrote managed xdg-desktop-portal-wlr config");
|
||||
let _ = Command::new("systemctl")
|
||||
.args(["--user", "try-restart", "xdg-desktop-portal-wlr.service"])
|
||||
.status();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The ScreenCast portal handshake (same shape as the capture module's portal thread, but it
|
||||
/// reports the fd + node id and parks until stopped — the zbus connection is the cast's
|
||||
/// lifetime). xdpw answers the source selection via the chooser, no dialog.
|
||||
fn portal_thread(setup_tx: Sender<Result<(OwnedFd, u32), String>>, stop: Arc<AtomicBool>) {
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::enumflags2::BitFlags;
|
||||
|
||||
// Multi-thread runtime: the zbus background reader must be pumped across the
|
||||
// create_session → select_sources → start handshake (see capture/linux.rs).
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
let _ = setup_tx.send(Err(format!("build tokio runtime: {e}")));
|
||||
return;
|
||||
}
|
||||
};
|
||||
let err_tx = setup_tx.clone();
|
||||
|
||||
rt.block_on(async move {
|
||||
let result: Result<()> = async {
|
||||
let proxy = Screencast::new().await.context(
|
||||
"connect ScreenCast portal (is xdg-desktop-portal running with the wlr backend?)",
|
||||
)?;
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("create_session")?;
|
||||
proxy
|
||||
.select_sources(
|
||||
&session,
|
||||
SelectSourcesOptions::default()
|
||||
.set_cursor_mode(CursorMode::Embedded)
|
||||
// xdpw offers MONITOR only; the chooser picks our output.
|
||||
.set_sources(BitFlags::from_flag(SourceType::Monitor))
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::DoNot),
|
||||
)
|
||||
.await
|
||||
.context("select_sources")?
|
||||
.response()
|
||||
.context("select_sources rejected")?;
|
||||
let streams = proxy
|
||||
.start(&session, None, Default::default())
|
||||
.await
|
||||
.context("start cast")?
|
||||
.response()
|
||||
.context("start response (chooser declined? check the xdpw config/chooser file)")?;
|
||||
let stream = streams
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned no streams")?
|
||||
.clone();
|
||||
let node_id = stream.pipe_wire_node_id();
|
||||
let fd = proxy
|
||||
.open_pipe_wire_remote(&session, Default::default())
|
||||
.await
|
||||
.context("open_pipe_wire_remote")?;
|
||||
|
||||
setup_tx
|
||||
.send(Ok((fd, node_id)))
|
||||
.map_err(|_| anyhow!("virtual-output opener went away"))?;
|
||||
|
||||
// Park, keeping `proxy` + `session` (the zbus connection) alive until stopped —
|
||||
// the cast is torn down when the connection drops.
|
||||
let _keep_alive = (&proxy, &session);
|
||||
while !stop.load(Ordering::Relaxed) {
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
if let Err(e) = result {
|
||||
let _ = err_tx.send(Err(format!("{e:#}")));
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user