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
+136
View File
@@ -190,6 +190,38 @@
} }
} }
}, },
"/api/v1/display/monitors": {
"get": {
"tags": [
"display"
],
"summary": "Physical monitors",
"description": "The heads this host actually has — for pinning capture at one (`PUNKTFUNK_CAPTURE_MONITOR`) and\nfor rendering a picker. Read-only: this never creates, moves or disables anything. Note these\nare *not* the managed virtual displays — those are `/display/state`. See\n`design/per-monitor-portal-capture.md` §5.1.",
"operationId": "getDisplayMonitors",
"responses": {
"200": {
"description": "The host's physical monitors",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MonitorsResponse"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/presets": { "/api/v1/display/presets": {
"get": { "get": {
"tags": [ "tags": [
@@ -3646,6 +3678,66 @@
} }
} }
}, },
"ApiMonitorInfo": {
"type": "object",
"description": "One physical monitor this host has, as the compositor reports it.",
"required": [
"connector",
"description",
"mode",
"x",
"y",
"scale",
"primary",
"enabled",
"managed",
"selected"
],
"properties": {
"connector": {
"type": "string",
"description": "Connector name (`DP-1`, `HDMI-A-2`) — the value `PUNKTFUNK_CAPTURE_MONITOR` takes."
},
"description": {
"type": "string",
"description": "Human label for a picker (`make model`, else the connector)."
},
"enabled": {
"type": "boolean",
"description": "Driven right now. A disabled head is still listed, so it can be explained rather than missing."
},
"managed": {
"type": "boolean",
"description": "Best-effort: this is one of OUR virtual displays, not a real head (reliable on KWin only)."
},
"mode": {
"type": "string",
"description": "`WIDTHxHEIGHT@HZ` of the current mode (size only when the refresh is unknown)."
},
"primary": {
"type": "boolean",
"description": "The compositor's primary/focused head."
},
"scale": {
"type": "number",
"format": "double",
"description": "Logical scale factor."
},
"selected": {
"type": "boolean",
"description": "True when `PUNKTFUNK_CAPTURE_MONITOR` currently names this monitor."
},
"x": {
"type": "integer",
"format": "int32",
"description": "Desktop-space top-left — what makes a head identifiable when two share a size."
},
"y": {
"type": "integer",
"format": "int32"
}
}
},
"ApiSelectedGpu": { "ApiSelectedGpu": {
"type": "object", "type": "object",
"description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)", "description": "The GPU the **next** session's pipeline will be created on, and why. (A preference change\napplies to the next session; a running session keeps the GPU it opened on.)",
@@ -4251,6 +4343,13 @@
"type": "object", "type": "object",
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].", "description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
"properties": { "properties": {
"capture_monitor": {
"type": [
"string",
"null"
],
"description": "**Mirror a physical monitor instead of creating a virtual display**: the connector name\n(`DP-1`, `HDMI-A-2`) sessions should stream, or `None` for the normal virtual-display path.\n\nOrthogonal to `preset`/lifecycle (like `game_session`): a preset change never clears it, and\n`#[serde(default)]` leaves existing `display-settings.json` files untouched. It is a\n**host-wide** setting, not per-client — the host-pinned decision of record in\n`design/per-monitor-portal-capture.md` §5.3. `PUNKTFUNK_CAPTURE_MONITOR` overrides it (see\n[`capture_monitor`]), so an appliance can pin in `host.env` without the console fighting it."
},
"ddc_power_off": { "ddc_power_off": {
"type": "boolean", "type": "boolean",
"description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched." "description": "EXPERIMENTAL (Windows): command physical monitors' panels off over DDC/CI (VCP 0xD6 →\nDPMS off) right before an `Exclusive` isolate deactivates them, and back on at restore.\nTargets the \"connected-but-dark head\" periodic-stutter class (monitor standby\nauto-input-scan / DP link churn while the virtual display is the sole active display) at\nthe monitor-firmware level. Best-effort — monitors without DDC/CI (or with it disabled in\nthe OSD) are skipped. Orthogonal to `preset` (like `game_session`): preserved across\npreset changes; `#[serde(default)]` = off so existing `display-settings.json` files are\nuntouched."
@@ -5720,6 +5819,43 @@
"reject" "reject"
] ]
}, },
"MonitorsResponse": {
"type": "object",
"description": "The host's physical monitors + which one capture is pinned to.",
"required": [
"monitors"
],
"properties": {
"compositor": {
"type": [
"string",
"null"
],
"description": "Compositor backend the enumeration came from (`kwin`, `mutter`, …), when one was resolved."
},
"error": {
"type": [
"string",
"null"
],
"description": "Why the list is empty, when enumeration failed (compositor unreachable, unsupported\nplatform). `None` with an empty list means \"asked, and there are none\"."
},
"monitors": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApiMonitorInfo"
},
"description": "The heads, ordered left-to-right by desktop position."
},
"pinned": {
"type": [
"string",
"null"
],
"description": "The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,\nso the console can show \"pinned to DP-2, which this host doesn't have\"."
}
}
},
"NativeClient": { "NativeClient": {
"type": "object", "type": "object",
"description": "A paired native (punktfunk/1) client.", "description": "A paired native (punktfunk/1) client.",
+24 -9
View File
@@ -288,19 +288,34 @@ pub fn rebuild_probe_active() -> bool {
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0 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`. /// Open the virtual-display driver for `compositor`.
/// ///
/// A `PUNKTFUNK_CAPTURE_MONITOR` pin routes to the **mirror** backend instead: the host streams /// A [`capture_monitor`] pin routes to the **mirror** backend instead: the host streams that
/// that physical head and creates no virtual display at all. Deliberately resolved here, at the one /// physical head and creates no virtual display at all. Deliberately resolved here, at the one place
/// place every session opens a display, so the pin can't be honored on one plane and ignored on /// every session opens a display, so the pin can't be honored on one plane and ignored on another —
/// another — it is a host-wide setting (`design/per-monitor-portal-capture.md` §5.3). /// it is a host-wide setting (`design/per-monitor-portal-capture.md` §5.3).
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> { pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
if let Some(connector) = pf_host_config::config().capture_monitor.as_deref() { if let Some(connector) = capture_monitor() {
return Ok(Box::new(mirror::MirrorDisplay::new( return Ok(Box::new(mirror::MirrorDisplay::new(compositor, connector)?));
compositor,
connector.to_string(),
)?));
} }
#[cfg(target_os = "linux")] #[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. /// startup. Orthogonal to `preset` (like `game_session`); `#[serde(default)]` = off.
#[serde(default)] #[serde(default)]
pub pnp_disable_monitors: bool, 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 { fn one() -> u32 {
@@ -271,6 +281,7 @@ impl Default for DisplayPolicy {
game_session: GameSession::default(), game_session: GameSession::default(),
ddc_power_off: false, ddc_power_off: false,
pnp_disable_monitors: false, pnp_disable_monitors: false,
capture_monitor: None,
} }
} }
} }
@@ -316,6 +327,12 @@ impl DisplayPolicy {
pub fn sanitized(mut self) -> Self { pub fn sanitized(mut self) -> Self {
self.version = 1; self.version = 1;
self.max_displays = self.max_displays.clamp(1, 16); 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 self
} }
} }
@@ -332,6 +349,7 @@ impl EffectivePolicy {
game_session: GameSession, game_session: GameSession,
ddc_power_off: bool, ddc_power_off: bool,
pnp_disable_monitors: bool, pnp_disable_monitors: bool,
capture_monitor: Option<String>,
) -> DisplayPolicy { ) -> DisplayPolicy {
DisplayPolicy { DisplayPolicy {
version: 1, version: 1,
@@ -345,10 +363,13 @@ impl EffectivePolicy {
positions, positions,
}, },
max_displays: self.max_displays, 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, game_session,
ddc_power_off, ddc_power_off,
pnp_disable_monitors, pnp_disable_monitors,
capture_monitor,
} }
} }
} }
@@ -791,12 +812,19 @@ mod tests {
let mut positions = BTreeMap::new(); let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 0, y: 0 }); positions.insert("1".to_string(), Position { x: 0, y: 0 });
positions.insert("7".to_string(), Position { x: 2560, y: 0 }); positions.insert("7".to_string(), Position { x: 2560, y: 0 });
let p = eff.with_manual_layout(positions, GameSession::Dedicated, true, true); let p = eff.with_manual_layout(
// The orthogonal axes (game-session, DDC power-off, PnP disable) are preserved through positions,
// the transform. 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_eq!(p.game_session, GameSession::Dedicated);
assert!(p.ddc_power_off); assert!(p.ddc_power_off);
assert!(p.pnp_disable_monitors); 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… // Preset drops to Custom so the explicit fields (incl. the layout) rule…
assert_eq!(p.preset, Preset::Custom); assert_eq!(p.preset, Preset::Custom);
// …every other behavior axis is preserved verbatim… // …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 explicit = arg("--monitor");
let want = explicit let want = explicit
.clone() .clone()
.or_else(|| pf_host_config::config().capture_monitor.clone()) .or_else(crate::vdisplay::capture_monitor)
.context( .context(
"no monitor named — pass --monitor <CONNECTOR> or set PUNKTFUNK_CAPTURE_MONITOR", "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 /// 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 /// `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). /// (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 { fn is_management_cli(args: &[String]) -> bool {
match args.first().map(String::as_str) { match args.first().map(String::as_str) {
Some("plugins") 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")] #[cfg(target_os = "linux")]
if !management_cli { if !management_cli {
if let Some(want) = pf_host_config::config().capture_monitor.as_deref() { refresh_capture_monitor_anchor("startup");
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"
),
}
}
} }
// Wire pf-vdisplay's display-lifecycle events into the SSE event bus (the subsystem crate emits a // 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"); println!("{compositor:?}: no monitors");
return Ok(()); return Ok(());
} }
let pinned = pf_host_config::config().capture_monitor.as_deref(); let pinned = vdisplay::capture_monitor();
println!("{compositor:?}:"); println!("{compositor:?}:");
for m in &monitors { for m in &monitors {
let mut tags = Vec::new(); let mut tags = Vec::new();
@@ -486,8 +505,11 @@ fn real_main() -> Result<()> {
if m.managed { if m.managed {
tags.push("punktfunk virtual display"); tags.push("punktfunk virtual display");
} }
if pinned.is_some_and(|p| p.eq_ignore_ascii_case(&m.connector)) { if pinned
tags.push("PINNED (PUNKTFUNK_CAPTURE_MONITOR)"); .as_deref()
.is_some_and(|p| p.eq_ignore_ascii_case(&m.connector))
{
tags.push("PINNED");
} }
println!( println!(
" {:<12} {:>13} at +{},+{} scale {} {}{}", " {:<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. // (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(), "ddc_power_off".into(),
"pnp_disable_monitors".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"); 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() Json(display_settings_state()).into_response()
} }
@@ -237,7 +244,12 @@ pub(crate) struct MonitorsResponse {
) )
)] )]
pub(crate) async fn get_display_monitors() -> Json<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. // 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() { let (compositor, listed) = tokio::task::spawn_blocking(|| match crate::vdisplay::detect() {
Ok(c) => (Some(c.id().to_string()), crate::vdisplay::monitors::list(c)), 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.game_session(),
store.ddc_power_off(), store.ddc_power_off(),
store.pnp_disable_monitors(), store.pnp_disable_monitors(),
store.get().capture_monitor,
); );
if let Err(e) = store.set(policy) { if let Err(e) = store.set(policy) {
return api_error( return api_error(
+12 -1
View File
@@ -414,5 +414,16 @@
"session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.", "session_game_grace_help": "Wie lange ein verschwundener Client Zeit hat zurückzukommen, bevor sein Spiel geschlossen wird. Die Konsole zeigt den Countdown; eine neue Verbindung bricht ihn ab.",
"session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert", "session_game_saved": "Sitzungs- und Spieleinstellungen gespeichert",
"session_game_inert": "Dieser Host kann keine Spiele starten hier bewirken diese Einstellungen nichts", "session_game_inert": "Dieser Host kann keine Spiele starten hier bewirken diese Einstellungen nichts",
"session_game_nested_note": "In einer gamescope-Spielsitzung läuft das Spiel *innerhalb* der gestreamten Anzeige und lebt daher genau so lange wie diese das entscheidet „Offen halten“ oben, nicht diese Einstellung. Ein bewusstes Stoppen reißt die Anzeige sofort ab und nimmt das Spiel mit, egal was du hier wählst." "session_game_nested_note": "In einer gamescope-Spielsitzung läuft das Spiel *innerhalb* der gestreamten Anzeige und lebt daher genau so lange wie diese das entscheidet „Offen halten“ oben, nicht diese Einstellung. Ein bewusstes Stoppen reißt die Anzeige sofort ab und nimmt das Spiel mit, egal was du hier wählst.",
"display_monitor_title": "Übertragener Bildschirm",
"display_monitor_intro": "Standardmäßig erstellt der Host für jeden Client einen eigenen virtuellen Bildschirm in dessen Auflösung. Stattdessen kannst du einen Bildschirm übertragen, den dieser Computer bereits hat — was auf diesem Monitor zu sehen ist, sieht auch der Client.",
"display_monitor_virtual": "Virtueller Bildschirm (Standard)",
"display_monitor_virtual_hint": "Jeder Client bekommt einen eigenen Bildschirm in eigener Auflösung.",
"display_monitor_mirror_hint": "Alle Clients sehen diesen Monitor in dessen Auflösung.",
"display_monitor_none": "Dieser Host meldet keine Monitore.",
"display_monitor_unavailable": "Monitore konnten auf diesem Host nicht ermittelt werden.",
"display_monitor_env_locked": "Auf diesem Host über PUNKTFUNK_CAPTURE_MONITOR festgelegt — dort entfernen, um hier zu wählen.",
"display_monitor_primary": "primär",
"display_monitor_disabled": "aus",
"display_monitor_saved": "Übertragener Bildschirm gespeichert"
} }
+12 -1
View File
@@ -414,5 +414,16 @@
"session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.", "session_game_grace_help": "How long a client that vanished has to come back before its game is closed. The console shows the countdown, and reconnecting cancels it.",
"session_game_saved": "Session and game settings saved", "session_game_saved": "Session and game settings saved",
"session_game_inert": "This host has no way to launch games, so these settings do nothing here", "session_game_inert": "This host has no way to launch games, so these settings do nothing here",
"session_game_nested_note": "On a gamescope game session the game runs *inside* the streamed display, so it lives exactly as long as that display does — which is what Keep alive above decides, not this setting. A deliberate Stop tears that display down at once and takes the game with it, whichever option you pick here." "session_game_nested_note": "On a gamescope game session the game runs *inside* the streamed display, so it lives exactly as long as that display does — which is what Keep alive above decides, not this setting. A deliberate Stop tears that display down at once and takes the game with it, whichever option you pick here.",
"display_monitor_title": "Streamed screen",
"display_monitor_intro": "By default the host creates its own virtual screen for each client, sized to that client. Instead, you can stream one of the screens this computer already has — what you see on that monitor is what the client sees.",
"display_monitor_virtual": "Virtual screen (default)",
"display_monitor_virtual_hint": "Each client gets its own screen at its own resolution.",
"display_monitor_mirror_hint": "Every client sees this monitor, at its resolution.",
"display_monitor_none": "This host reports no monitors.",
"display_monitor_unavailable": "Monitors could not be listed on this host.",
"display_monitor_env_locked": "Pinned by PUNKTFUNK_CAPTURE_MONITOR on this host — unset it to choose here.",
"display_monitor_primary": "primary",
"display_monitor_disabled": "off",
"display_monitor_saved": "Streamed screen saved"
} }
+160
View File
@@ -0,0 +1,160 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import type { FC } from "react";
import { ApiError } from "@/api/fetcher";
import {
getGetDisplayMonitorsQueryKey,
getGetDisplaySettingsQueryKey,
useGetDisplayMonitors,
useGetDisplaySettings,
useSetDisplaySettings,
} from "@/api/gen/display/display";
import type { ApiMonitorInfo } from "@/api/gen/model";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { QueryState } from "@/components/query-state";
import { cn } from "@/lib/utils";
import { m } from "@/paraglide/messages";
/**
* **Streamed screen** (design/per-monitor-portal-capture.md §5.3): stream one of the host's real
* monitors instead of creating a virtual display per client.
*
* Deliberately a HOST-wide choice, not per-client — it is the decision of record for this feature,
* and it is also what keeps input honest: the injector is host-lifetime and shared by every
* concurrent session, so "which screen do absolute coordinates land on" can only have one answer.
*
* Saves on selection (like the policy card above) — there is no Save button to miss. The pin is a
* field of the display policy, so it rides the same PUT.
*/
export const MonitorCard: FC = () => {
const qc = useQueryClient();
const monitors = useGetDisplayMonitors();
const settings = useGetDisplaySettings();
const save = useSetDisplaySettings();
const policy = settings.data?.settings;
const rows = monitors.data?.monitors ?? [];
// The host reports the EFFECTIVE pin — an env-pinned appliance shows its real answer here even
// though the console cannot change it.
const pinned = monitors.data?.pinned ?? null;
// `PUNKTFUNK_CAPTURE_MONITOR` outranks the stored policy, so a host pinned in its unit's
// environment is read-only here: offering controls that silently lose to the env would be worse
// than saying so.
const envLocked = !!pinned && policy?.capture_monitor !== pinned;
const choose = (connector: string | null) => {
if (!policy || envLocked) return;
save.mutate(
{ data: { ...policy, capture_monitor: connector } },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: getGetDisplaySettingsQueryKey() });
qc.invalidateQueries({ queryKey: getGetDisplayMonitorsQueryKey() });
toast.success(m.display_monitor_saved());
},
},
);
};
const busy = save.isPending;
const error = save.error instanceof ApiError ? save.error.message : undefined;
const row = (
key: string,
selected: boolean,
title: string,
hint: string,
tags?: ReturnType<typeof Badge>[],
onSelect?: () => void,
) => (
<button
key={key}
type="button"
disabled={busy || envLocked || !onSelect}
onClick={onSelect}
aria-pressed={selected}
className={cn(
"flex w-full items-start justify-between gap-4 rounded-md border p-3 text-left transition-colors",
selected ? "border-primary bg-primary/5" : "hover:bg-muted/50",
(busy || envLocked) && "cursor-not-allowed opacity-60",
)}
>
<span className="flex flex-col gap-1">
<span className="flex items-center gap-2 font-medium">
{title}
{tags}
</span>
<span className="text-sm text-muted-foreground">{hint}</span>
</span>
</button>
);
const monitorRow = (mon: ApiMonitorInfo) => {
const tags = [
mon.primary ? (
<Badge key="p" variant="secondary">
{m.display_monitor_primary()}
</Badge>
) : null,
!mon.enabled ? (
<Badge key="d" variant="outline">
{m.display_monitor_disabled()}
</Badge>
) : null,
].filter(Boolean) as ReturnType<typeof Badge>[];
return row(
mon.connector,
pinned?.toLowerCase() === mon.connector.toLowerCase(),
`${mon.connector}${mon.description}`,
`${mon.mode} · ${m.display_monitor_mirror_hint()}`,
tags,
// A disabled head cannot be streamed (the host refuses with that reason), so don't
// offer it as a choice — it is listed so "why isn't it here?" has an answer.
mon.enabled && !mon.managed ? () => choose(mon.connector) : undefined,
);
};
return (
<Card>
<CardHeader>
<CardTitle>{m.display_monitor_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="max-w-prose text-sm text-muted-foreground">
{m.display_monitor_intro()}
</p>
{envLocked && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.display_monitor_env_locked()}
</p>
)}
<QueryState
isLoading={monitors.isLoading}
error={monitors.error}
refetch={monitors.refetch}
>
<div className="flex flex-col gap-2">
{row(
"__virtual__",
!pinned,
m.display_monitor_virtual(),
m.display_monitor_virtual_hint(),
undefined,
() => choose(null),
)}
{rows.map(monitorRow)}
{rows.length === 0 && (
<p className="text-sm text-muted-foreground">
{monitors.data?.error
? m.display_monitor_unavailable()
: m.display_monitor_none()}
</p>
)}
</div>
</QueryState>
{error && <p className="text-sm text-destructive">{error}</p>}
</CardContent>
</Card>
);
};
+2
View File
@@ -3,6 +3,7 @@ import type { FC } from "react";
import { useLocale } from "@/lib/i18n"; import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages"; import { m } from "@/paraglide/messages";
import { DisplaySection } from "./DisplayCard"; import { DisplaySection } from "./DisplayCard";
import { MonitorCard } from "./MonitorCard";
import { SessionGameCard } from "./SessionGameCard"; import { SessionGameCard } from "./SessionGameCard";
/** /**
@@ -21,6 +22,7 @@ export const SectionDisplays: FC = () => {
<div className="flex flex-col gap-card"> <div className="flex flex-col gap-card">
<h1 className="text-2xl font-semibold">{m.nav_displays()}</h1> <h1 className="text-2xl font-semibold">{m.nav_displays()}</h1>
<DisplaySection /> <DisplaySection />
<MonitorCard />
<SessionGameCard /> <SessionGameCard />
</div> </div>
</Section> </Section>