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:
@@ -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",
|
||||
)?;
|
||||
|
||||
@@ -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 {} {}{}",
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user