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