fix(host/display): a screen picker that cannot stream a screen now says so instead of saving

The Windows console's "Streamed screen" picker SAVED and then did nothing. `capture_monitor` is
platform-neutral, so the PUT persisted; every consumer of it is Linux-gated, so a virtual display
was still created on connect. The operator got a control that acknowledged the click and changed
nothing — the worst of the three possible behaviours.

The root cause is not a missing gate, it is a missing BACKEND. Per-monitor capture is Linux/portal
only: `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]` because `pf-capture` has no
Windows entry point that can capture an arbitrary head. Its sole Windows entry point is
`open_idd_push`, a frame channel pushed by our OWN IddCx virtual display; DXGI Desktop Duplication
was deliberately REMOVED (`windows/dxgi.rs` keeps the GPU-preference hook only to stop DXGI
reparenting the virtual display off the pinned adapter, and says so). So there is nothing for a pin
to aim at, and exposing the picker got ahead of that.

Decision of record (user, this session): mark it unsupported now rather than build the Windows
backend here. A Windows mirror backend is a real feature gap and a project of its own — it needs a
duplication capturer plus everything the IDD-push path carries today (cursor sidechannel, 444,
10-bit, PyroWave, HDR) re-plumbed through it — not a follow-on to an enumeration commit.

Three places, so the answer is consistent wherever it is asked:

* `MonitorsResponse.pin_supported` — a CAPABILITY, reported by the build that would have to honor
  the pin rather than sniffed from the OS client-side. When a Windows mirror backend lands this
  flips and the console needs no change. `pinned` stays `None` off-Linux deliberately, and now says
  why: it is what the console highlights as "sessions stream this", and highlighting a head nothing
  will capture is the same lie in a different place.
* `enforced` drops `capture_monitor` off Linux. That list is exactly the "which controls are live vs.
  coming soon" contract, and claiming this one unconditionally is what let the picker ship enabled.
* The PUT drops a non-Linux `capture_monitor` instead of storing it, and logs that it did. COERCED,
  not rejected with a 400: this PUT is WHOLE-OBJECT, so a host that already stored a pin would have
  every later settings save rejected over a field the operator cannot see — taking the other axes
  down with it. This way such a policy self-heals on the next write.

Console: the picker renders read-only with an explanation, reusing the `envLocked` shape rather than
inventing a second one (`locked = envLocked || !pinSupported`). Managed heads were ALREADY excluded
from selection here (`mon.enabled && !mon.managed`), which is the "grey out, never filter blindly"
rule working as intended — the heads stay listed and explicable. `pin_supported` defaults to TRUE
when absent so an older host, which only ever shipped this picker where it worked, is not
retroactively locked out.

⚠️ `api/openapi.json` is hand-patched — punktfunk-host does not build on macOS, so the spec could not
be regenerated from the binary. The shape was verified by running the real generator over it: orval
emits `pin_supported: boolean` (required, no `?`) on `MonitorsResponse`.

Verified: `bun run codegen` (orval + paraglide + i18n parity: 434 messages, en + de), `bun run build`,
`bun run lint` (tsc --noEmit) all clean. Rust verified separately on .173 — see the next commit's
note if that lands, since neither xcheck target covers punktfunk-host.
This commit is contained in:
2026-07-29 00:18:27 +02:00
parent ed021a13ee
commit 50531c8e9e
5 changed files with 86 additions and 20 deletions
+6 -1
View File
@@ -5831,7 +5831,8 @@
"type": "object",
"description": "The host's physical monitors + which one capture is pinned to.",
"required": [
"monitors"
"monitors",
"pin_supported"
],
"properties": {
"compositor": {
@@ -5855,6 +5856,10 @@
},
"description": "The heads, ordered left-to-right by desktop position."
},
"pin_supported": {
"type": "boolean",
"description": "Whether this build can actually STREAM one of these monitors.\n\nEnumeration and capture are separate capabilities, and on Windows only the first exists: the\nheads below are real and worth showing (they explain the topology, and `/display/state`\ncross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a\nframe channel pushed by our OWN IddCx virtual display. There is no desktop-duplication\ncapturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so\n`vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.\n\nThe console renders the picker read-only on `false`. Reported as a capability rather than\nsniffed client-side from the OS so the answer comes from the build that would have to honor\nit — when a Windows mirror backend lands, this flips and the UI needs no change."
},
"pinned": {
"type": [
"string",
+61 -15
View File
@@ -71,27 +71,34 @@ pub(crate) fn display_settings_state() -> DisplaySettingsState {
})
})
.collect();
let mut enforced: Vec<String> = vec![
"keep_alive".into(),
"topology".into(),
"mode_conflict".into(),
"identity".into(),
"layout".into(),
"game_session".into(),
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
// (`vdisplay/windows/manager.rs`); stored-but-inert elsewhere.
"ddc_power_off".into(),
"pnp_disable_monitors".into(),
];
// `capture_monitor` routes every session to the MIRROR backend, and that backend exists only on
// Linux — `vdisplay::open`'s mirror arm is `#[cfg(target_os = "linux")]`, because `pf-capture`
// has no Windows entry point that can capture an arbitrary head. This list is precisely the
// "which controls are live vs. coming soon" contract, so claiming it unconditionally is what let
// the Windows console offer a picker that saved and then did nothing
// (`design/per-monitor-portal-capture.md` §5.3).
if cfg!(target_os = "linux") {
enforced.push("capture_monitor".into());
}
DisplaySettingsState {
effective: settings.effective(),
settings,
configured,
presets,
custom_presets: policy::load_custom_presets(),
enforced: vec![
"keep_alive".into(),
"topology".into(),
"mode_conflict".into(),
"identity".into(),
"layout".into(),
"game_session".into(),
// EXPERIMENTAL, Windows-only in effect: acted on at the `exclusive` isolate
// (`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(),
],
enforced,
}
}
@@ -135,6 +142,24 @@ pub(crate) async fn get_display_settings() -> Json<DisplaySettingsState> {
pub(crate) async fn set_display_settings(
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
) -> Response {
#[cfg_attr(target_os = "linux", allow(unused_mut))]
let mut policy = policy;
// A pin nothing can honor must not be STORED as though it were. Off Linux there is no mirror
// backend (`MonitorsResponse::pin_supported`), so drop the field rather than persisting a
// setting whose only effect would be to mislead: the console re-reads this state after the PUT
// and would otherwise render a screen as "streamed" while every session kept creating a virtual
// display. Coerced rather than rejected with a 400 on purpose — this PUT is WHOLE-OBJECT, so a
// host that already stored a pin (before this build) would have every later settings save
// rejected over a field the operator cannot even see, taking the other axes down with it. This
// way such a policy self-heals on the next write, and the response body shows the truth
// immediately.
#[cfg(not(target_os = "linux"))]
if let Some(dropped) = policy.capture_monitor.take() {
tracing::warn!(
"management API: ignoring capture_monitor={dropped:?} — streaming a chosen physical \
monitor is Linux-only (no Windows mirror backend); the pin was NOT stored"
);
}
// `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux
// registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch).
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
@@ -222,6 +247,19 @@ pub(crate) struct MonitorsResponse {
/// The configured `PUNKTFUNK_CAPTURE_MONITOR`, if any — reported even when it matches nothing,
/// so the console can show "pinned to DP-2, which this host doesn't have".
pinned: Option<String>,
/// Whether this build can actually STREAM one of these monitors.
///
/// Enumeration and capture are separate capabilities, and on Windows only the first exists: the
/// heads below are real and worth showing (they explain the topology, and `/display/state`
/// cross-references them), but `pf-capture`'s sole Windows entry point is `open_idd_push` — a
/// frame channel pushed by our OWN IddCx virtual display. There is no desktop-duplication
/// capturer to point at a chosen head (DXGI Desktop Duplication was deliberately removed), so
/// `vdisplay::open` has no mirror arm outside Linux and a pin could not be honored.
///
/// The console renders the picker read-only on `false`. Reported as a capability rather than
/// sniffed client-side from the OS so the answer comes from the build that would have to honor
/// it — when a Windows mirror backend lands, this flips and the UI needs no change.
pin_supported: bool,
/// Why the list is empty, when enumeration failed (compositor unreachable, unsupported
/// platform). `None` with an empty list means "asked, and there are none".
error: Option<String>,
@@ -248,8 +286,15 @@ pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
// sessions will actually mirror, not just what the console last wrote.
#[cfg(target_os = "linux")]
let pinned = crate::vdisplay::capture_monitor();
// Off Linux there is no mirror backend, so there is nothing a pin could aim (see
// `MonitorsResponse::pin_supported`). Kept `None` DELIBERATELY rather than reporting the stored
// value: `pinned` is what the console highlights as "this is the screen sessions stream", and a
// highlight on a head nothing will ever capture is precisely the lie this endpoint is here to
// stop telling. `pin_supported: false` is how the unsupportedness is reported instead.
#[cfg(not(target_os = "linux"))]
let pinned: Option<String> = None;
// Enumeration works on Windows; capture of an enumerated head does not.
let pin_supported = cfg!(target_os = "linux");
// Enumeration shells out / round-trips D-Bus + Wayland (and on Windows walks the CCD
// database, which can serialize on the display-config lock), so keep it off the async worker.
let (compositor, listed) = tokio::task::spawn_blocking(|| {
@@ -297,6 +342,7 @@ pub(crate) async fn get_display_monitors() -> Json<MonitorsResponse> {
compositor,
monitors,
pinned,
pin_supported,
error,
})
}
+1
View File
@@ -430,6 +430,7 @@
"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_unsupported": "Das Übertragen eines dieser Bildschirme wird auf diesem Host noch nicht unterstützt — das gibt es bisher nur auf Linux-Hosts. Die Bildschirme sind hier aufgeführt, damit du siehst, was dieser Computer hat; Clients bekommen weiterhin ihren eigenen virtuellen Bildschirm.",
"display_monitor_primary": "primär",
"display_monitor_disabled": "aus",
"display_monitor_saved": "Übertragener Bildschirm gespeichert"
+1
View File
@@ -430,6 +430,7 @@
"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_unsupported": "Streaming one of these screens isn't supported on this host yet — it's available on Linux hosts only. The screens are listed so you can see what this computer has; clients keep getting their own virtual screen.",
"display_monitor_primary": "primary",
"display_monitor_disabled": "off",
"display_monitor_saved": "Streamed screen saved"
+17 -4
View File
@@ -42,9 +42,17 @@ export const MonitorCard: FC = () => {
// 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;
// The host says whether it can honor a pin at all. Windows enumerates its heads but has no
// backend that can capture one (see `MonitorsResponse.pin_supported`), and this card used to
// offer the choice anyway: the PUT persisted, nothing consumed it, and a virtual display was
// still created on connect. Defaults to TRUE when the field is absent so an older host — which
// only ever shipped this picker where it worked — is not retroactively locked out.
const pinSupported = monitors.data?.pin_supported ?? true;
// Both reasons produce the same read-only card; only the explanation above it differs.
const locked = envLocked || !pinSupported;
const choose = (connector: string | null) => {
if (!policy || envLocked) return;
if (!policy || locked) return;
save.mutate(
{ data: { ...policy, capture_monitor: connector } },
{
@@ -71,13 +79,13 @@ export const MonitorCard: FC = () => {
<button
key={key}
type="button"
disabled={busy || envLocked || !onSelect}
disabled={busy || locked || !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",
(busy || locked) && "cursor-not-allowed opacity-60",
)}
>
<span className="flex flex-col gap-1">
@@ -124,7 +132,12 @@ export const MonitorCard: FC = () => {
<p className="max-w-prose text-sm text-muted-foreground">
{m.display_monitor_intro()}
</p>
{envLocked && (
{!pinSupported && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.display_monitor_unsupported()}
</p>
)}
{pinSupported && envLocked && (
<p className="text-sm text-amber-600 dark:text-amber-500">
{m.display_monitor_env_locked()}
</p>