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:
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -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…
|
||||
|
||||
Reference in New Issue
Block a user