feat: the streamed screen is a persisted setting, pickable from the console

P4 of design/per-monitor-portal-capture.md. The pin was an env var read
once at startup, which a console picker can never write — so it becomes a
field of the display policy (orthogonal to presets, like game_session), and
`vdisplay::capture_monitor()` resolves env-over-policy: an appliance that
pinned in its unit's host.env stays pinned there, and a console click
cannot re-aim a machine whose operator already declared the answer.

Read per `open` rather than cached, so a picker change takes effect on the
next session instead of the next host restart. The host re-resolves the
input anchor on every policy write — including clearing it when the pin is
cleared, or a later virtual-display session inherits an anchor aimed at a
monitor it is not showing.

`with_manual_layout` carries the pin through like the other orthogonal
axes: without that, saving a display ARRANGEMENT would silently swap the
streamed screen back to virtual.

Console: a "Streamed screen" card listing the host's real monitors beside
"Virtual screen (default)", saving on selection. A disabled head is listed
but not selectable (so "why isn't it here?" has an answer), and an
env-pinned host renders read-only with the reason rather than offering
controls that silently lose to the environment.

Verified on GNOME/Mutter: pinning via the policy file alone (no env) routes
sessions to the mirror and anchors input; setting the env to a different
connector overrides it, exactly as documented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-27 23:51:41 +02:00
co-authored by Claude Opus 5
parent 358cfa4be4
commit d461d889c3
10 changed files with 1291 additions and 893 deletions
+24 -9
View File
@@ -288,19 +288,34 @@ pub fn rebuild_probe_active() -> bool {
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0
}
/// The monitor this host mirrors instead of creating a virtual display, or `None` for the normal
/// virtual-display path.
///
/// Precedence: **`PUNKTFUNK_CAPTURE_MONITOR` wins over the stored policy.** An appliance pins in
/// `host.env` and must stay pinned there — a console click (or a stale settings file) should not be
/// able to re-aim a machine whose operator declared the answer in its unit's environment. With the
/// env unset, the console's persisted choice applies, which is what makes a picker possible at all:
/// the env is read once at startup, so it could never be what a UI writes.
///
/// Read per `open` rather than cached, so a console change takes effect on the next session instead
/// of at the next host restart.
pub fn capture_monitor() -> Option<String> {
if let Some(env) = pf_host_config::config().capture_monitor.as_deref() {
return Some(env.to_string());
}
policy::prefs().get().capture_monitor
}
/// Open the virtual-display driver for `compositor`.
///
/// A `PUNKTFUNK_CAPTURE_MONITOR` pin routes to the **mirror** backend instead: the host streams
/// that physical head and creates no virtual display at all. Deliberately resolved here, at the one
/// place every session opens a display, so the pin can't be honored on one plane and ignored on
/// another — it is a host-wide setting (`design/per-monitor-portal-capture.md` §5.3).
/// A [`capture_monitor`] pin routes to the **mirror** backend instead: the host streams that
/// physical head and creates no virtual display at all. Deliberately resolved here, at the one place
/// every session opens a display, so the pin can't be honored on one plane and ignored on another —
/// it is a host-wide setting (`design/per-monitor-portal-capture.md` §5.3).
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
#[cfg(target_os = "linux")]
if let Some(connector) = pf_host_config::config().capture_monitor.as_deref() {
return Ok(Box::new(mirror::MirrorDisplay::new(
compositor,
connector.to_string(),
)?));
if let Some(connector) = capture_monitor() {
return Ok(Box::new(mirror::MirrorDisplay::new(compositor, connector)?));
}
#[cfg(target_os = "linux")]
{
+32 -4
View File
@@ -246,6 +246,16 @@ pub struct DisplayPolicy {
/// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
#[serde(default)]
pub pnp_disable_monitors: bool,
/// **Mirror a physical monitor instead of creating a virtual display**: the connector name
/// (`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.
///
/// Orthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and
/// `#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a
/// **host-wide** setting, not per-client — the host-pinned decision of record in
/// `design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see
/// [`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it.
#[serde(default)]
pub capture_monitor: Option<String>,
}
fn one() -> u32 {
@@ -271,6 +281,7 @@ impl Default for DisplayPolicy {
game_session: GameSession::default(),
ddc_power_off: false,
pnp_disable_monitors: false,
capture_monitor: None,
}
}
}
@@ -316,6 +327,12 @@ impl DisplayPolicy {
pub fn sanitized(mut self) -> Self {
self.version = 1;
self.max_displays = self.max_displays.clamp(1, 16);
// A picker that clears its selection sends `""`; that means "no pin", not "match the
// monitor named empty string" — same normalization the env knob does.
self.capture_monitor = self
.capture_monitor
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
self
}
}
@@ -332,6 +349,7 @@ impl EffectivePolicy {
game_session: GameSession,
ddc_power_off: bool,
pnp_disable_monitors: bool,
capture_monitor: Option<String>,
) -> DisplayPolicy {
DisplayPolicy {
version: 1,
@@ -345,10 +363,13 @@ impl EffectivePolicy {
positions,
},
max_displays: self.max_displays,
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them).
// Preserve the orthogonal axes (EffectivePolicy doesn't carry them). Dropping any of
// them here would mean "saving a display arrangement silently cleared my setting" —
// for `capture_monitor` that would swap the streamed screen out from under the operator.
game_session,
ddc_power_off,
pnp_disable_monitors,
capture_monitor,
}
}
}
@@ -791,12 +812,19 @@ mod tests {
let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 0, y: 0 });
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true);
// The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through
// the transform.
let p = eff.with_manual_layout(
positions,
GameSession::Dedicated,
true,
true,
Some("DP-2".into()),
);
// The orthogonal axes (game-session, DDC power-off, PnP disable, capture-monitor pin) are
// preserved through the transform — arranging displays must not clear an unrelated setting.
assert_eq!(p.game_session, GameSession::Dedicated);
assert!(p.ddc_power_off);
assert!(p.pnp_disable_monitors);
assert_eq!(p.capture_monitor.as_deref(), Some("DP-2"));
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
assert_eq!(p.preset, Preset::Custom);
// …every other behavior axis is preserved verbatim…
+1 -1
View File
@@ -502,7 +502,7 @@ pub fn mirror_test(args: &[String]) -> Result<()> {
let explicit = arg("--monitor");
let want = explicit
.clone()
.or_else(|| pf_host_config::config().capture_monitor.clone())
.or_else(crate::vdisplay::capture_monitor)
.context(
"no monitor named — pass --monitor <CONNECTOR> or set PUNKTFUNK_CAPTURE_MONITOR",
)?;
+68 -46
View File
@@ -191,6 +191,67 @@ fn main() {
/// prints an alarming `SetProcessDpiAwarenessContext … "access denied"` WARN on a plain
/// `plugins add`. `service run` is the SCM-launched host itself, so it is explicitly NOT lightweight
/// (it must keep the hook — the hybrid-GPU ACCESS_LOST fix depends on it).
/// Resolve the effective monitor pin (env, else the stored policy) and aim absolute input at that
/// head — then report it. Called at startup (an operator sets the pin in a `host.env` and then has
/// no session to watch, so this is where a typo has to surface) and again whenever the console
/// writes the policy, so a picker change re-aims input without a host restart.
///
/// The anchor lives HERE rather than in the mirror backend for two reasons: pf-vdisplay must not
/// depend on pf-inject (its crate doc), and the anchor is a host-level pin anyway — the injector is
/// host-lifetime and shared by every concurrent session, so there is nothing per-session to track
/// (`design/per-monitor-portal-capture.md` §7.2).
#[cfg(target_os = "linux")]
pub(crate) fn refresh_capture_monitor_anchor(context: &str) {
let Some(want) = pf_vdisplay::capture_monitor() else {
// No pin (or the console just cleared one): stop aiming input at a monitor we are no
// longer mirroring, or a later virtual-display session inherits a stale anchor.
pf_inject::set_absolute_anchor(None);
return;
};
match pf_vdisplay::detect().and_then(pf_vdisplay::monitors::list) {
Ok(ms) => match pf_vdisplay::monitors::resolve(&ms, &want) {
Ok(m) => {
// Match the libei region by the head's ORIGIN: two monitors can share a size — and
// a mirrored head's region is not the client's size at all — so size matching would
// put the pointer on the wrong screen.
pf_inject::set_absolute_anchor(Some(pf_inject::AbsoluteAnchor {
origin: Some((m.x, m.y)),
mapping_id: None,
}));
tracing::info!(
context,
connector = %m.connector,
description = %m.description,
mode = %m.mode_label(),
at = %format!("+{}+{}", m.x, m.y),
"capture monitor: sessions will mirror this monitor (no virtual display) and \
absolute input is anchored to it"
);
}
// Left unanchored on purpose: a pin that resolves to nothing must not aim input at a
// guess. The session's own `create` fails with the same reason.
Err(e) => {
pf_inject::set_absolute_anchor(None);
tracing::warn!(
context,
error = %e,
"capture monitor: the pinned monitor is not on this host — sessions will fail \
to start until it is corrected or cleared"
);
}
},
Err(e) => {
pf_inject::set_absolute_anchor(None);
tracing::warn!(
context,
error = %format!("{e:#}"),
monitor = %want,
"capture monitor: a monitor is pinned but the monitors could not be enumerated"
);
}
}
}
fn is_management_cli(args: &[String]) -> bool {
match args.first().map(String::as_str) {
Some("plugins")
@@ -234,51 +295,9 @@ fn real_main() -> Result<()> {
);
}
// Resolve a `PUNKTFUNK_CAPTURE_MONITOR` pin at startup: report it (the operator sets it in a
// host.env and then has no session to watch, so this is where a typo has to surface), and aim
// absolute input at that head.
//
// The anchor is set HERE rather than inside the mirror backend for two reasons: pf-vdisplay must
// not depend on pf-inject (its crate doc), and the anchor is a host-level pin anyway — the
// injector is host-lifetime and shared by every concurrent session, so there is nothing
// per-session to track (`design/per-monitor-portal-capture.md` §7.2).
#[cfg(target_os = "linux")]
if !management_cli {
if let Some(want) = pf_host_config::config().capture_monitor.as_deref() {
match pf_vdisplay::detect().and_then(pf_vdisplay::monitors::list) {
Ok(ms) => match pf_vdisplay::monitors::resolve(&ms, want) {
Ok(m) => {
// Match the libei region by the head's ORIGIN: two monitors can share a
// size — and a mirrored head's region is not the client's size at all — so
// size matching would put the pointer on the wrong screen.
pf_inject::set_absolute_anchor(Some(pf_inject::AbsoluteAnchor {
origin: Some((m.x, m.y)),
mapping_id: None,
}));
tracing::info!(
connector = %m.connector,
description = %m.description,
mode = %m.mode_label(),
at = %format!("+{}+{}", m.x, m.y),
"PUNKTFUNK_CAPTURE_MONITOR: sessions will mirror this monitor (no \
virtual display) and absolute input is anchored to it"
);
}
// Left unanchored on purpose: a pin that resolves to nothing must not aim input
// at a guess. The session's own `create` fails with the same reason.
Err(e) => tracing::warn!(
error = %e,
"PUNKTFUNK_CAPTURE_MONITOR names no monitor on this host — sessions will \
fail to start until it is corrected or unset"
),
},
Err(e) => tracing::warn!(
error = %format!("{e:#}"),
monitor = %want,
"PUNKTFUNK_CAPTURE_MONITOR is set but the monitors could not be enumerated"
),
}
}
refresh_capture_monitor_anchor("startup");
}
// Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a
@@ -473,7 +492,7 @@ fn real_main() -> Result<()> {
println!("{compositor:?}: no monitors");
return Ok(());
}
let pinned = pf_host_config::config().capture_monitor.as_deref();
let pinned = vdisplay::capture_monitor();
println!("{compositor:?}:");
for m in &monitors {
let mut tags = Vec::new();
@@ -486,8 +505,11 @@ fn real_main() -> Result<()> {
if m.managed {
tags.push("punktfunk virtual display");
}
if pinned.is_some_and(|p| p.eq_ignore_ascii_case(&m.connector)) {
tags.push("PINNED (PUNKTFUNK_CAPTURE_MONITOR)");
if pinned
.as_deref()
.is_some_and(|p| p.eq_ignore_ascii_case(&m.connector))
{
tags.push("PINNED");
}
println!(
" {:<12} {:>13} at +{},+{} scale {} {}{}",
+14 -1
View File
@@ -88,6 +88,9 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(),
"pnp_disable_monitors".into(),
// Linux-only in effect: routes every session to the mirror backend
// (design/per-monitor-portal-capture.md).
"capture_monitor".into(),
],
}
}
@@ -141,6 +144,10 @@ pub(crate) async fn set_display_settings(
);
}
tracing::info!("management API: display policy updated");
// The policy carries the capture-monitor pin, so a picker change must re-aim absolute input now
// rather than at the next host restart — and must clear the anchor when the pin is cleared.
#[cfg(target_os = "linux")]
crate::refresh_capture_monitor_anchor("display policy updated");
Json(display_settings_state()).into_response()
}
@@ -237,7 +244,12 @@ pub(crate) struct MonitorsResponse {
)
)]
pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
let pinned = pf_host_config::config().capture_monitor.clone();
// The EFFECTIVE pin (env override, else the stored policy) — so the picker highlights what
// sessions will actually mirror, not just what the console last wrote.
#[cfg(target_os = "linux")]
let pinned = crate::vdisplay::capture_monitor();
#[cfg(not(target_os = "linux"))]
let pinned: Option<String> = None;
// Enumeration shells out / round-trips D-Bus + Wayland, so keep it off the async worker.
let (compositor, listed) = tokio::task::spawn_blocking(|| match crate::vdisplay::detect() {
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)),
@@ -392,6 +404,7 @@ pub(crate) async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutReques
store.game_session(),
store.ddc_power_off(),
store.pnp_disable_monitors(),
store.get().capture_monitor,
);
if let Err(e) = store.set(policy) {
return api_error(