Files
enricobuehlerandClaude Fable 5 c23fc84bef
ci / rust-arm64 (pull_request) Successful in 1m44s
ci / web (pull_request) Successful in 1m3s
ci / bun-nix (pull_request) Successful in 29s
ci / docs-site (pull_request) Successful in 1m24s
ci / docs-drift (pull_request) Successful in 23s
ci / rust (pull_request) Successful in 12m33s
docs(guides): trim round 2 — each page sheds one prose category, no fact rows, headings or commands touched
The category sacrificed, per page (RFC 'screenshots over prose' / phase-2 handoff item 3):
- client-settings.md (4368→3144 words): the why-rationale narrative around each setting; every
  default, number, availability list, env var and link stays.
- virtual-displays.md (3724→3015): status/history notes (the 'What's live today' tracker
  paragraph, 'Windows has always lingered 10 s') and design rationale; compositor-specific
  operational facts stay.
- plugins.mdx (2835→2418): narrative duplication — restart instructions stated twice, story
  asides the linked plugin repos carry.
- input.md (2499→2403) and wake-on-lan.md (2286→2186): rationale asides and historical notes
  ('the plugin used to fire a packet itself…'); these two are per-platform fact tables end to
  end, so only a light pass was honest.

15712→13166 words over the five biggest Guides (−16%). Zero heading changes (anchors stable),
docs-drift + docs-links green, docs-site build + lint green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 01:17:16 +02:00

345 lines
19 KiB
Markdown

---
title: Virtual displays
description: Control how Punktfunk creates, keeps alive, and arranges the virtual displays it streams — presets, keep-alive, exclusive vs. extend, and persistent per-client scaling.
---
When a client connects, Punktfunk creates a **virtual display** at exactly that client's resolution
and refresh, renders your desktop or game onto it, and streams it. This page covers the **policy**
for that display: how long it survives a disconnect, whether it takes over your physical monitors,
what happens when a second client connects, and how desktop environments remember per-client
settings like scaling.
Set it in the **web console** (the **Virtual displays** page), or edit
`~/.config/punktfunk/display-settings.json` (`%ProgramData%\punktfunk\display-settings.json` on
Windows). A change applies to the **next** connection.
> **You rarely need to touch this.** The default matches how Punktfunk has always worked; reach for
> a preset when you want a specific experience.
>
> Monitors that stayed dark, or a streamed screen showing only wallpaper? Go to
> [Troubleshooting](#troubleshooting).
To stream a monitor the host **already has** instead, see
[Stream a real monitor instead](#stream-a-real-monitor-instead) — it turns most of this page off.
## Stream a real monitor instead
> **Linux only.** A Windows host enumerates its monitors but has no backend that can capture one —
> the Streamed screen card is read-only there.
Set **Virtual displays → Streamed screen** in the console to a listed monitor and Punktfunk streams
that physical monitor instead of creating a virtual display; every client sees it at *its*
resolution.
- The monitor is **never touched** — not resized, moved, disabled or restored. Keep-alive, topology
and multi-monitor layout don't apply.
- **The resolution is the monitor's**, not yours. A client asking for a different one is told no and
scales its own picture; mid-stream resize is off.
- **Every client sees the same screen** — two clients are two viewers of one monitor.
- Naming a monitor this host **doesn't have, while it has others**, is a **hard error**: the session
fails with `no monitor named "DP-9" — this host has: HDMI-1`. The exception is a session with
**no physical heads at all** (nested or headless compositor): the pin is set aside with a log
warning, you get an ordinary virtual display, and the pin applies again on the next session with
real heads.
- **Virtual screen (default)** in the same card puts you back on the normal path.
Supported on **KDE/KWin**, **GNOME/Mutter**, **Sway/wlroots**, **Hyprland** and **gamescope Game
Mode** — each through the compositor's own screen-recording API, so there is **no chooser dialog**
(a background [service](/docs/running-as-a-service) has nobody to answer one). On gamescope only the
head the session is driving is listed — mirroring attaches to the session's own composited stream,
so the screen keeps showing what the person in front of it sees. A *nested* or headless gamescope
has no head, so the picker is empty there.
### Naming the monitor from the host
Monitors are named by **connector**`HDMI-A-1`, `DP-2`, `eDP-1`. List this host's (Linux-only
subcommands, like the setting):
```sh
punktfunk-host list-monitors
```
```
Kwin:
HDMI-A-1 1920x1080@60 at +0,+0 scale 1 Dell U2412M [primary]
DP-2 2560x1440@144 at +1920,+0 scale 1 ACME 27
```
To pin it **from the host's configuration** instead — the appliance route — set it in
[`host.env`](/docs/configuration):
```sh
PUNKTFUNK_CAPTURE_MONITOR=HDMI-A-1
```
The environment variable **wins over the console setting**; the console shows the card as locked
while it's set.
Check the whole path — mirror, capture, frames — without a client:
```sh
punktfunk-host mirror-test --monitor HDMI-A-1 --seconds 20
```
Compositor screen recording is **damage-driven**: an idle desktop produces almost no frames, so move
the mouse on the host while it runs or a working mirror reads as a stall.
### Absolute input follows the pin
Pinning a monitor also re-aims **absolute** mouse and pen input to that head's origin, so a click
lands where you point on *that* screen. Heads are matched by position, not size. The host resolves
the pin at startup and whenever the console writes it — no restart; the log line is
`capture monitor: …`.
To check it with no client involved:
```sh
punktfunk-host anchor-test --monitor HDMI-A-1
```
It lists this host's heads, says whether the box has the same-size pair the matching exists for,
walks the pointer through the centre and corners, and prints the region it mapped into. `--none`
runs the same walk unanchored, as an A/B. The anchor rides the **libei** injector — the
GNOME/Mutter backend. On KWin, Sway and Hyprland the host injects through a different protocol, and
`anchor-test` stops and says so rather than reporting a green run that proves nothing.
## Pick a preset
Select one in the console and you're done. Each expands to a bundle of the options documented
further down.
| Preset | What it's for |
|---|---|
| **Default** | Most setups. Reconnects resume quickly, the streamed output becomes the whole desktop, extra viewers each get their own screen. |
| **Headless box** | A monitorless machine you only stream from. Game and display survive disconnects indefinitely (keep-alive **forever**); whoever connects next takes the box over. Release it from the console when you're done. |
| **Shared desktop** | A PC you also use in person. Never blanks your real monitors, never leaves a leftover display behind; extra viewers each get their own screen. |
| **Hot-desk** | One person at a time — roam between your own devices with an instant reconnect. Anyone else is told the box is busy; each device+resolution keeps its own scaling. |
| **Workstation** | Your multi-monitor daily driver. Displays come back exactly where you arranged them, each client keeps its own settings, the desktop is yours alone. |
## Save your own preset
- **Save as preset** — names the settings currently in force (all the options below **plus**
*Dedicated game sessions*) and adds it to the picker alongside the built-ins.
- **Apply** — writes exactly those settings, like picking a built-in.
- **Edit / delete** — rename, update to your current settings, or remove. Deleting never changes
what's running.
The built-in presets leave *Dedicated game sessions* alone, so switching presets never changes your
game-launch routing; a **custom preset captures your full setup**, including that axis. Custom
presets live on the host in `display-presets.json` (next to `display-settings.json`); editing a
preset never disturbs a running session.
## Options reference
Choose **Custom** in the console to set these directly.
### Keep alive
How long the virtual display survives after your last session disconnects. On a gamescope game host
this also keeps the **game itself running**.
- **Off** — tear the display down at session end.
- **A duration** (seconds) — a reconnect inside the window drops you straight back in, with no
re-negotiation and no desktop reshuffle.
- **Forever** — keep it until you stop the host or **release it** from the console (**Virtual
displays** → *Release*). The headless-box model.
Default: **10 seconds**.
**A reconnect always resumes the kept display** — even a second or two after dropping.
**Deliberately quitting** (closing the client, not a network drop) tears the display down at once,
skipping the linger. How quickly a *dropped* client is noticed is the QUIC idle timeout — 8 s by
default, tunable with `PUNKTFUNK_IDLE_TIMEOUT_MS` (see
[Legacy environment knobs](#legacy-environment-knobs)).
> **Keep-alive + Exclusive keeps your physical monitors dark after you disconnect**, until the
> linger expires or you release the display. Intentional for a dedicated gaming box — on a machine
> whose monitors you also use in person, use **Shared desktop**.
### Topology
What Punktfunk does with your monitor layout while it streams.
- **Extend** — add the virtual display alongside your real monitors; touch nothing else.
- **Primary** — make the virtual display your primary output; physical monitors stay on.
- **Exclusive** — the virtual display becomes your **only** enabled output (physical monitors are
disabled, then restored when streaming ends), so panels and windows land on it.
- **Automatic** *(default)* — Exclusive on Windows and on an auto-detected KDE/GNOME desktop;
Extend when you've pinned a specific compositor with `PUNKTFUNK_COMPOSITOR`.
Per-backend support:
| | KWin | Mutter/GNOME | Sway/wlroots · Hyprland | Windows |
|---|---|---|---|---|
| Extend | ✅ | ✅ | ✅ | ✅ |
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ |
| Exclusive | ✅ | ✅ | ✅ | ✅ |
**Sway/wlroots and Hyprland have no primary-output concept**; they have a *focused* output, and the
host points that at the streamed display — at session start, and again immediately before it
launches anything from your library, which is what puts the game on the streamed display. Primary
therefore behaves as Extend (the host says so in the log), and a window that opens *later* follows
whatever has focus then — clicking a physical monitor mid-launch can still pull a window over.
**Exclusive** on both compositor families:
- Punktfunk only disables monitors it did not create, so a second concurrent client never goes dark.
- On Hyprland the restore is a `hyprctl reload` — nothing else re-enables a monitor a rule disabled.
The reload re-reads your Hyprland config; settings changed at runtime with `hyprctl keyword` are
dropped and a non-Lua config re-runs its `exec =` lines (`exec-once` is not). Only happens if a
session actually disabled something.
### Conflict handling · identity · layout
- **Conflict handling** — a *different* client connects mid-stream asking for a different
resolution: give it its own display (**separate**), take the box over (**steal**), share the
existing display at its current mode (**join**), or refuse it (**reject**). On **Windows** a
second client is **rejected** ("host busy") even under `separate` — two clients can't yet share
one virtual display's capture there. A same-client *reconnect* never conflicts — it resumes.
- **Identity** — whether each client gets a **stable display identity** so your desktop environment
remembers its settings (see [Persistent scaling](#persistent-scaling)): one shared identity, one
**per client**, or one **per client + resolution**.
- **Layout / max displays** — several clients as monitors of one desktop: side by side (**auto**) or
exactly where you arrange them in the console (**manual**, keyed to each client), up to **max
displays**. Arrange them on the **Virtual displays** page once two or more are streaming.
### Dedicated game sessions
How a session that *launches a game from [your library](/docs/game-library)* is served (Linux
hosts):
- **Auto** (default) — the launch rides whatever session the box is in: the managed Steam session
on a Steam Deck / Bazzite couch box, a bare gamescope on a plain distro, or your live KDE / GNOME
/ Sway desktop.
- **Dedicated** — every library launch gets its **own headless gamescope at your exact resolution
and refresh**, with just the game inside. Steam titles launch with the client hidden
(`steam -silent`); non-Steam titles start almost instantly (gamescope up in ~1 s). Combined with
**keep alive**, the game keeps running when you disconnect.
Dedicated needs `gamescope` installed; without it a launch falls back to **Auto**. This axis is
independent of the preset. On a box already in Steam game mode, a dedicated Steam launch frees game
mode's Steam first and restores it when the session ends. (GameStream / Moonlight launches follow
the same routing.)
## When a game ends, and when a session does
Two switches, on the **Virtual displays** page under **When a game or a session ends**, tie a
session to the game the host launched for it. They apply to every store and both protocols — and
only to a game **this host launched for the session**: a game you started yourself is never touched.
### When the game exits
**End the session** (default) — quit the game and your client goes back to its own library, on
every path (live desktop, attached gamescope, Moonlight). **Keep streaming** if you stream the
desktop and treat the game as incidental.
### When the session ends
Whether stopping — or losing — a session also closes the game.
- **Leave it running** (default). Nothing is ever closed.
- **Close it on Stop** — closing the client, or *Stop* in the console, closes the game. A network
drop does not.
- **Always close it** — a drop closes it too, after a **reconnect window** (5 minutes by default).
Reconnect inside the window and nothing happens; the console shows the countdown, with an **End
now** button.
Closing a game costs whatever it hadn't saved, which is why nothing closes by default. The host
asks first — a polite close, the same as clicking the window's X — and only forces the issue after
ten seconds of being ignored.
> **Keep alive and this setting are different clocks.** Keep-alive decides how long the *display*
> outlives a disconnect (10 s default); the reconnect window decides how long the *game* does
> (5 min). A display set to **Forever** stays up regardless of what happens to the game.
### On a gamescope session, the display has the final say
When a launch gets its **own gamescope** — a dedicated game session, the usual setup on a Steam
Deck or Bazzite couch box — the game runs *inside* the streamed display and lives exactly as long
as it does, so **Keep alive decides, not the setting above**:
| you disconnect by | what happens to the game |
|---|---|
| pressing **Stop** (or the console's stop) | the display tears down at once — keep-alive is skipped for a real stop — and the game goes with it, even on *Leave it running* |
| dropping out (network, sleep) | the display lingers for your keep-alive window, then tears down; the game ends with it |
| dropping out, keep-alive **Forever** | the display is pinned, so the game genuinely survives — and *Always close it* still ends it when the reconnect window closes |
On a gamescope box, "leave the game running after I disconnect" means **keep-alive Forever** (or a
window long enough to come back in). On a desktop session — KWin, GNOME, Sway — the game is an
ordinary process and the setting above is the whole story.
### Automation
The host publishes `game.running` and `game.exited` events (the latter says whether the player quit
it or the host closed it), so a hook or plugin can react without polling. See
[Automation](/docs/automation).
## Persistent scaling
Set your display **scaling** once and have it stick across reconnects. Each client gets a *stable
display identity*, so your desktop environment keys its per-monitor settings to it.
| Host | Supported | How |
|---|---|---|
| **Windows** | ✅ today | Set scaling in Settings while streaming — Windows remembers it per client. |
| **KDE / KWin** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. |
| **GNOME / Mutter** | ✅ today | GNOME's virtual-monitor API exposes no stable identity, so the **host persists the scale itself**: set scaling in Settings while streaming — the host captures it per client and reapplies on reconnect. |
| **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. |
## Legacy environment knobs
These `PUNKTFUNK_*` variables still work, but the console (and `display-settings.json`) supersede
them — when a settings file exists, it wins.
| Legacy knob | Now expressed as |
|---|---|
| `PUNKTFUNK_MONITOR_LINGER_MS` | **Keep alive** → duration *(Windows)* |
| `PUNKTFUNK_NO_ISOLATE` | **Topology** → Extend *(Windows)* |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | **Topology** → Exclusive (when set) / Extend (when `0`) |
One knob has no console equivalent — transport tuning, not display policy:
- **`PUNKTFUNK_IDLE_TIMEOUT_MS`** (host, default `8000`) — how long before a *dropped* client is
declared gone, which is when a kept display starts its linger (or is freed). Lower it (e.g.
`3000`) to reclaim kept displays sooner; it's clamped to ≥1 s and its keep-alive ping scales with
it, so a live session never false-disconnects. A deliberate quit is instant regardless. Also
`--idle-timeout-ms` on `punktfunk1-host`.
## Troubleshooting
**My physical monitors stayed off after I disconnected.** Keep-alive is set together with Exclusive
topology — the display is kept for the linger window. Release it from the console (**Virtual
displays**), or switch to the **Shared desktop** preset.
**The virtual output shows only my wallpaper.** Your topology is Extend, so the streamed display is
an empty extension. Use **Primary** or **Exclusive** so your desktop lands on it.
**KWin can't create the virtual output.** On a normal Plasma session KWin runs its **DRM backend**,
which creates virtual outputs at any version. The 6.5.6 floor applies only to the **virtual
backend** (`kwin_wayland --virtual`, headless and test sessions) — below that the request fails
with "Could not find output". On **KWin 6.6+** that same message also covers an output KWin *did*
create and then left disabled; [KDE Plasma](/docs/kde#troubleshooting) walks that one. See
[requirements](/docs/requirements).
**Reconnecting into game mode reconnects cleanly now.** On a Steam Deck / Bazzite box,
disconnect/reconnect within game mode reuses the still-warm session (or cleanly recreates it), and
switching between game mode and the desktop mid-stream follows the switch. If a launched game
**exits**, a dedicated session ends and returns you to your library; a game mode / desktop session
keeps streaming.
**My keep-alive / topology / layout settings do nothing.** Check whether **Streamed screen** is set
to a real monitor — those options are about a display Punktfunk created. Switch the card back to
*Virtual screen (default)*.
**The console won't let me change Streamed screen.** `PUNKTFUNK_CAPTURE_MONITOR` is set in this
host's [`host.env`](/docs/configuration) and outranks the console. Unset it (and restart the host).
**My session fails with "no monitor named …".** The pinned connector isn't among this host's
monitors — renamed, unplugged, or the host is in a different session. Run
`punktfunk-host list-monitors` to see the real names. Punktfunk will not quietly stream a different
screen.
**My couch box's TV stayed on the streamed session after I disconnected.** With the **Headless
box** preset (keep alive = *forever*), a managed Steam session is held indefinitely — return to
game mode on the box (or restart the host) to hand the TV back.