Compare commits

...
7 Commits
Author SHA1 Message Date
enricobuehlerandClaude Opus 4.8 f4fe4d0792 chore(release): bump workspace version to 0.19.2
android-screenshots / screenshots (push) Successful in 2m59s
windows-host / package (push) Successful in 9m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m48s
android / android (push) Successful in 12m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m5s
arch / build-publish (push) Successful in 12m2s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m45s
audit / cargo-audit (push) Successful in 2m10s
audit / bun-audit (push) Successful in 14s
ci / web (push) Successful in 54s
ci / docs-site (push) Successful in 1m6s
apple / swift (push) Successful in 1m57s
ci / bench (push) Successful in 5m41s
ci / rust (push) Successful in 18m52s
decky / build-publish (push) Successful in 20s
deb / build-publish (push) Successful in 8m50s
deb / build-publish-host (push) Successful in 9m24s
web-screenshots / screenshots (push) Successful in 3m7s
flatpak / build-publish (push) Successful in 6m27s
docker / deploy-docs (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
release / apple (push) Successful in 1h4m50s
apple / screenshots (push) Successful in 23m21s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m40s
linux-client-screenshots / screenshots (push) Successful in 7m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m0s
An all-fixes patch on top of 0.19.1, all host-side:

- KDE Plasma display takeover moved off kscreen-doctor to in-process
  kde_output_management_v2 (86d5d666), with the >60 Hz custom-mode install
  ported too (64511af4) — a KWin host whose kscreen-doctor wedges now disables
  its physicals and hands over the streamed output in ~2.4 ms instead of hanging
  ~26 s and never taking over.
- The gamescope managed takeover is display-manager-flavor-aware (e35dad52) so
  switching a non-SDDM Linux desktop (Nobara / plasmalogin) into Game Mode no
  longer start-limits the display manager into a permanent black screen, and
  in-stream 'Switch to Desktop' is honored under it (a3e21d92).
- Windows display isolation anchors the kept sources at the desktop origin
  (9b241d9d) so an origin-less desktop no longer fails 0x57, with restore
  guaranteed never to leave the desktop all-dark.
- Windows pen/touch/absolute-mouse map over the streamed output's rect rather
  than the whole virtual desktop (087e7d04), so absolute input lands correctly
  when a physical monitor is kept on beside the streamed display.

Nothing moved on the wire (protocol 2) or at the C ABI (13), so no embedder
rebuild and 0.18/0.19 peers keep mixing freely. The Windows virtual-display
driver protocol is unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 17:56:08 +02:00
enricobuehlerandClaude Fable 5 087e7d0405 fix(inject/windows): map absolute input over the streamed output's rect, not the whole desktop
audit / cargo-audit (push) Successful in 2m13s
audit / bun-audit (push) Successful in 15s
arch / build-publish (push) Successful in 12m46s
windows-host / package (push) Successful in 12m53s
android / android (push) Successful in 13m48s
ci / web (push) Successful in 56s
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 15m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m9s
ci / bench (push) Successful in 5m28s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m10s
ci / rust (push) Successful in 19m38s
decky / build-publish (push) Successful in 27s
deb / build-publish (push) Successful in 8m37s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m27s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 44s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 1m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m39s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Failing after 1m15s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m5s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4m54s
docker / deploy-docs (push) Skipped
deb / build-publish-host (push) Successful in 10m7s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m26s
flatpak / build-publish (push) Successful in 6m33s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m42s
release / apple (push) Successful in 56m19s
apple / screenshots (push) Successful in 23m11s
Pen, touch, and absolute mouse arrive normalized to the STREAMED display's
frame, but pointer_windows::to_screen and sendinput's MouseMoveAbs mapped
them over the whole virtual desktop — correct only when the virtual display
is the sole active display (Exclusive topology). In Extend — a physical
monitor kept on beside the virtual output, or an Exclusive isolate degraded
to the 0x57 keep-physicals fallback — the streamed output sits at a non-zero
origin, so every sample landed shifted and mis-scaled. The pen exposed it
first (field report 2026-07-24): a stylus is strictly absolute, with no
closed-loop correction onto the target like a cursor.

New pf-inject::stream_target (Windows): the host publishes the streamed
output's CCD target id at capture bring-up (one central site —
capture_virtual_output covers the native and GameStream planes); mapping
sites resolve its current desktop rect through pf-win-display's
source_desktop_rect — the same resolver the cursor-readback poller uses, so
inject and readback always agree — TTL-cached (250 ms) because a
group-layout re-arrange moves a live output's origin mid-session. No target
set / never resolved falls back to the whole virtual desktop (the historical
mapping, still right for Exclusive topology and devtest).

One change in to_screen covers pen + touch; MouseMoveAbs converts the same
desktop pixel into the 0..65535 MOUSEEVENTF_VIRTUALDESK coordinate, closing
the identical latent absolute-mouse bug (apollo-comparison open item #14/#30).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 17:38:36 +02:00
enricobuehlerandClaude Opus 4.8 64511af4d3 fix(kwin): install >60Hz custom modes in-process too — KWin path is now kscreen-free
audit / bun-audit (push) Successful in 35s
audit / cargo-audit (push) Successful in 2m34s
decky / build-publish (push) Successful in 17s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 11s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 47s
deb / build-publish-host (push) Successful in 9m56s
deb / build-publish (push) Successful in 11m17s
android / android (push) Successful in 12m31s
docker / deploy-docs (push) Successful in 29s
arch / build-publish (push) Successful in 15m14s
apple / swift (push) Successful in 1m21s
apple / screenshots (push) Successful in 4m50s
release / apple (push) Successful in 12m45s
flatpak / build-publish (push) Successful in 7m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 15m58s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m18s
windows-host / package (push) Successful in 16m32s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m19s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m14s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 5m28s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 4m25s
ci / rust (push) Successful in 17m55s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 59s
ci / bench (push) Successful in 5m45s
Follow-up to the previous commit: the only KWin topology op still shelling out to
kscreen-doctor was the >60 Hz custom-mode INSTALL (`set_custom_refresh`). Port it
to `kde_output_management_v2` so the whole KWin path is kscreen-free on modern KWin.

`kwin_output_mgmt::set_custom_mode` builds a one-entry `kde_mode_list_v2`
(set_resolution / set_refresh_rate / set_reduced_blanking=full / add_mode), applies
it via `kde_output_configuration_v2.set_custom_modes` (since 18), waits for KWin to
generate the mode (its CVT generator may align the width down — matched with the
same height-exact / width-within-8 / refresh-within-1Hz gate as before), then
selects it — which changes the output size and drives the sacrificial-birth stream
renegotiation. `kwin::create`'s want_high branch tries this first and only resolves
a kscreen id + calls `set_custom_refresh` if it returns None (pre-6.6 KWin without
set_custom_modes, or the compositor not answering) — so a >60 Hz session on a box
where kscreen-doctor wedges no longer eats a 5 s resolve timeout either.

Bonus: `set_custom_modes` REPLACES the custom list, so reconnects are idempotent —
no more one-custom-mode-per-connect growth of the user's display list.

Verified: cargo test -p pf-vdisplay (73 pass), clippy -D warnings clean, fmt clean.
The set_custom_modes round-trip is proven live on KWin 6.6.4: apply=applied, and a
1648x928@75 request generates 1648x928@74.901 (CVT fractional refresh, caught by the
1 Hz tolerance). The mode SELECT + full >60 Hz stream is still owed on-glass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:47:34 +02:00
enricobuehlerandClaude Opus 4.8 86d5d66660 fix(kwin): drive display topology over kde_output_management_v2, not kscreen-doctor
A KDE host on Nobara stopped disabling its physical screens and creating the
virtual output the moment it updated: streaming still came up, but bring-up took
~26 s and the streamed output never became the desktop (`also_disabled=[]`).

Root cause is not our topology logic — it's that every `kscreen-doctor` call on
the reporter's session wedges. kscreen-doctor drives libkscreen, which (per
setup) waits on the kscreen KDED module over D-Bus; when that layer is stuck it
blocks in its own connect and never returns, so all five topology queries hit
their 5 s budget and got killed (host log). Reproduced live on a Nobara / KWin
6.6.4 box: `kscreen-doctor -j` there times out at 8 s, every time.

But the compositor's OWN Wayland is fully responsive on that same session — the
host just created a virtual output over it via zkde_screencast. So drive the
topology (resolve our output, take primary, disable the physical/bootstrap
outputs, capture their modes, re-enable on teardown, position) directly over
`kde_output_management_v2` + `kde_output_device_v2` instead of shelling out.
On that same Nobara box the in-process path binds management (v19) and
enumerates the outputs in 2.4 ms — ~3400x faster than the 8 s hang, and immune
to whatever wedges the standalone tool.

- vendor kde-output-management-v2 / kde-output-device-v2 (KWin advertises mgmt
  v19, device v20); generate client bindings inline (the interdependent-protocol
  module split from the wayland-protocols crate; needs the `bitflags` dep for the
  device protocol's bitfield enums).
- new `kwin_output_mgmt`: bounded enumerate-then-apply over one Wayland
  connection; every wait is time-bounded so a genuinely wedged compositor
  degrades to `handled = false` and the old kscreen-doctor path still runs.
- `kwin::create` topology + `apply_position` prefer the in-process path (address
  our output by its stable device UUID, supersede-robust) with kscreen-doctor as
  the fallback. The 60 Hz path now makes ZERO kscreen-doctor calls; only the
  >60 Hz custom-mode install still shells out (its in-process port is a follow-up).

Verified on Linux: cargo test -p pf-vdisplay (73 pass), clippy --all-targets
-D warnings clean, fmt clean, punktfunk-host checks clean. In-process enumeration
proven live against KWin 6.6.4 (the reporter's env); the disable/apply path is
not yet on-glass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-24 12:47:33 +02:00
enricobuehlerandClaude Fable 5 a3e21d9227 feat(vdisplay/gamescope): honor in-stream 'Switch to Desktop' under the managed DM takeover
ci / web (push) Successful in 59s
ci / docs-site (push) Successful in 1m7s
apple / swift (push) Successful in 1m21s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / bench (push) Successful in 6m2s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m32s
apple / screenshots (push) Successful in 6m21s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6m27s
deb / build-publish-host (push) Successful in 9m39s
docker / deploy-docs (push) Successful in 29s
deb / build-publish (push) Successful in 13m6s
android / android (push) Successful in 17m28s
arch / build-publish (push) Successful in 18m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m9s
windows-host / package (push) Successful in 24m2s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m12s
ci / rust (push) Successful in 26m49s
First on-glass test of the DM-flavor takeover confirmed the black-screen
fix, but exposed that the managed model could not switch back: Steam's
in-stream session-select is a silent no-op while the display manager is
stopped (every config-write branch in os-session-select requires the DM
to be running), so the capture loss it causes just relaunched game mode.

The user pass of the switch script does leave one durable trace — the
~/.config/steamos-session-select sentinel, written before any DM check.
The managed launch now baselines its mtime; a capture loss with the
sentinel advanced reads as the switch request and is honored by
replaying the switch with the DM up: restore the display manager, run
the distro's own os-session-select (its internal pkexec is authorized
allow_any by the distro policy, so it works from the host's sessionless
context), then stop the autologin game-mode unit so Relogin lands in
the newly selected desktop. The capture-loss re-detection then follows
KWin as it comes up; a 120 s post-honor grace stops the rebuild loop
from racing the booting desktop back into game mode (superseded early
if the box's own game-mode unit reappears — the desktop→game leg stays
fast). The baseline is re-recorded on crash-restore so a pre-existing
sentinel never reads as a fresh request. Every verb in the sequence was
live-validated on the Nobara repro VM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:49:24 +02:00
enricobuehlerandClaude Fable 5 e35dad529b fix(vdisplay/gamescope): DM-flavor-aware session takeover — stop masking Nobara's plasmalogin to death
Field report (Nobara 43 HTPC): switching the host to Steam Game Mode
mid-stream permanently black-screened the box. Live-proven root cause on
a Nobara repro VM: our takeover masks the box's gamescope-session-plus
unit, plasmalogin's Relogin=true then fails its session Exec repeatedly
and trips systemd's start limit within ~1 s — the display manager dies,
and our restore verb (unmask + user start) cannot bring a seatless
gamescope back. Only 'reset-failed + restart' of the DM recovers.

The takeover is now display-manager-flavor-aware:
- SDDM (Bazzite/SteamOS) keeps the proven mask+SIGKILL path unchanged.
- plasmalogin/unknown DMs never mask: with privilege (root or an
  operator polkit rule scoped to the DM unit — documented) the host
  stops the DM for the stream and restores it with reset-failed +
  restart (PUNKTFUNK_RECOVER_SESSION_CMD as fallback), recorded in the
  persisted takeover state so a host crash still restores; without
  privilege the managed takeover degrades to ATTACH and mirrors the
  box's live Game Mode instead of destabilizing the seat. Both legs of
  the privileged cycle live-verified on the repro VM (headless managed
  session works with zero login sessions; render nodes are 0666).
  A loaded-but-inactive leftover instance never triggers the DM stop.

Companion fixes from the same triage:
- ensure_box_gamescope_mode gains the attach-only rebuild-probe guard
  both managed paths already had (stale post-capture-loss detection
  restarted the box's unit), and no longer re-modes a box that drives a
  physical display — attach mirrors on-glass; re-mode is the
  headless-box model.
- Capture-loss rebuilds targeting gamescope get a 100 s budget: the
  40 s budget expired inside the first 45 s Steam-cold-start launch
  attempt, a guaranteed single-shot failure.
- A PUNKTFUNK_COMPOSITOR pin now WARNs once per capture loss when the
  live session no longer matches it (the pin disables
  session-following — the reporter's original stream-death trigger).
- A managed session that took nothing over (client gamescope pin beside
  a live desktop) is stopped on disconnect instead of being orphaned
  forever.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:49:24 +02:00
enricobuehlerandClaude Fable 5 9b241d9d7b fix(pf-win-display): anchor the kept sources at the desktop origin when isolating
ci / docs-site (push) Successful in 1m0s
ci / web (push) Successful in 1m4s
apple / swift (push) Successful in 1m22s
decky / build-publish (push) Successful in 34s
ci / bench (push) Successful in 6m36s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 46s
apple / screenshots (push) Successful in 6m35s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6m51s
deb / build-publish (push) Successful in 12m42s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish-host (push) Successful in 13m31s
android / android (push) Successful in 15m54s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7m18s
arch / build-publish (push) Successful in 16m19s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m34s
docker / deploy-docs (push) Successful in 29s
ci / rust (push) Successful in 20m38s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m34s
Round 3 of the field-reported exclusive-topology 0x57, with the reporter's
retest logs finally separating the variable: the SAME isolate call converged
rc=0 whenever a kept member already sat at (0,0), and failed 0x57 on every
shape — doomed-path-carried AND keep-only escalation — whenever the doomed
physical held the origin. A committable config must still contain a primary
(a source pinned exactly at (0,0)); deactivating the display that held it
while the kept virtual stays at its EXTEND offset supplies an origin-less
desktop, which Windows rejects wholesale. Translate the kept sources rigidly
so the top-left-most lands on the origin; sets already covering (0,0) are
untouched so plain re-commits stay byte-identical.

Also from the same logs:
- restore_displays_ccd now guarantees the desk is never left all-dark: the
  saved snapshot can be unappliable (0x64a — it pinned a virtual target
  incarnation removed before teardown) or apply cleanly yet re-light nothing
  (snapshotted while an earlier failed teardown had the physicals off). If no
  external physical is active after the apply while one is connected, fall
  back to the database EXTEND preset. Internal panels don't count — a closed
  lid must not be forced back on.
- the final isolate failure now names the surviving targets instead of
  asserting 'a non-virtual display stayed active' — the logs showed a sibling
  VIRTUAL display as the survivor (linger-expiry shrink), and the wording sent
  triage the wrong way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 10:21:47 +02:00
22 changed files with 3091 additions and 162 deletions
Generated
+29 -27
View File
@@ -2194,7 +2194,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.19.1" version = "0.19.2"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2299,7 +2299,7 @@ dependencies = [
[[package]] [[package]]
name = "libvpl-sys" name = "libvpl-sys"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
@@ -2334,7 +2334,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2823,7 +2823,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]] [[package]]
name = "pf-capture" name = "pf-capture"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2844,7 +2844,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-client-core" name = "pf-client-core"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2868,7 +2868,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-clipboard" name = "pf-clipboard"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2886,7 +2886,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-console-ui" name = "pf-console-ui"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2907,7 +2907,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-encode" name = "pf-encode"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -2931,7 +2931,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-ffvk" name = "pf-ffvk"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"ash", "ash",
"bindgen", "bindgen",
@@ -2940,7 +2940,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-frame" name = "pf-frame"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"libc", "libc",
@@ -2952,7 +2952,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-gpu" name = "pf-gpu"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-host-config", "pf-host-config",
@@ -2966,11 +2966,11 @@ dependencies = [
[[package]] [[package]]
name = "pf-host-config" name = "pf-host-config"
version = "0.19.1" version = "0.19.2"
[[package]] [[package]]
name = "pf-inject" name = "pf-inject"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
@@ -2981,6 +2981,7 @@ dependencies = [
"pf-driver-proto", "pf-driver-proto",
"pf-host-config", "pf-host-config",
"pf-paths", "pf-paths",
"pf-win-display",
"punktfunk-core", "punktfunk-core",
"reis", "reis",
"tokio", "tokio",
@@ -2998,14 +2999,14 @@ dependencies = [
[[package]] [[package]]
name = "pf-paths" name = "pf-paths"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"tracing", "tracing",
] ]
[[package]] [[package]]
name = "pf-presenter" name = "pf-presenter"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3020,10 +3021,11 @@ dependencies = [
[[package]] [[package]]
name = "pf-vdisplay" name = "pf-vdisplay"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ashpd", "ashpd",
"bitflags",
"bytemuck", "bytemuck",
"futures-util", "futures-util",
"hex", "hex",
@@ -3050,7 +3052,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-win-display" name = "pf-win-display"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-paths", "pf-paths",
@@ -3062,7 +3064,7 @@ dependencies = [
[[package]] [[package]]
name = "pf-zerocopy" name = "pf-zerocopy"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ash", "ash",
@@ -3269,7 +3271,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -3285,7 +3287,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -3301,7 +3303,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-session" name = "punktfunk-client-session"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"pf-client-core", "pf-client-core",
@@ -3316,7 +3318,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"async-channel", "async-channel",
"ffmpeg-next", "ffmpeg-next",
@@ -3335,7 +3337,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -3367,7 +3369,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3451,7 +3453,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3465,7 +3467,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
@@ -3488,7 +3490,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]] [[package]]
name = "pyrowave-sys" name = "pyrowave-sys"
version = "0.19.1" version = "0.19.2"
dependencies = [ dependencies = [
"bindgen", "bindgen",
"cmake", "cmake",
+1 -1
View File
@@ -48,7 +48,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" } ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package] [workspace.package]
version = "0.19.1" version = "0.19.2"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+3
View File
@@ -48,6 +48,9 @@ xkbcommon = "0.8"
usbip-sim = { path = "../punktfunk-host/vendor/usbip-sim" } usbip-sim = { path = "../punktfunk-host/vendor/usbip-sim" }
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
# The streamed-output desktop rect (CCD source rect) absolute input maps into — the same
# resolver the cursor-readback poller uses, so inject and readback always agree.
pf-win-display = { path = "../pf-win-display" }
windows = { version = "0.62", features = [ windows = { version = "0.62", features = [
"Win32_Foundation", "Win32_Foundation",
"Win32_Security", "Win32_Security",
@@ -41,9 +41,8 @@ use windows::Win32::UI::Input::Pointer::{
POINTER_FLAG_UPDATE, POINTER_INFO, POINTER_PEN_INFO, POINTER_TOUCH_INFO, POINTER_FLAG_UPDATE, POINTER_INFO, POINTER_PEN_INFO, POINTER_TOUCH_INFO,
}; };
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, PEN_FLAG_BARREL, PEN_FLAG_ERASER, PEN_FLAG_INVERTED, PEN_FLAG_NONE, PEN_FLAG_BARREL, PEN_FLAG_ERASER, PEN_FLAG_INVERTED, PEN_FLAG_NONE, PEN_MASK_PRESSURE,
PEN_MASK_PRESSURE, PEN_MASK_ROTATION, PEN_MASK_TILT_X, PEN_MASK_TILT_Y, PT_PEN, PT_TOUCH, PEN_MASK_ROTATION, PEN_MASK_TILT_X, PEN_MASK_TILT_Y, PT_PEN, PT_TOUCH, TOUCH_FLAG_NONE,
SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN, TOUCH_FLAG_NONE,
TOUCH_MASK_NONE, TOUCH_MASK_NONE,
}; };
@@ -149,22 +148,14 @@ unsafe fn inject_following_desktop(
/// Windows pen pressure is 0..1024 (vs the wire's full-scale u16). /// Windows pen pressure is 0..1024 (vs the wire's full-scale u16).
const WIN_PEN_PRESSURE_MAX: u32 = 1024; const WIN_PEN_PRESSURE_MAX: u32 = 1024;
/// Map a normalized [0,1] coordinate pair onto virtual-desktop pixels the same surface the /// Map a normalized [0,1] coordinate pair onto desktop pixels over the STREAMED output's rect
/// SendInput absolute mouse targets, so pen, touch, and pointer all land identically. /// ([`crate::stream_target`]) — the surface the SendInput absolute mouse targets too, so pen,
/// touch, and pointer all land identically. The wire normalizes over the streamed display's
/// frame, not the desktop: mapping over the whole virtual desktop is only right when they
/// coincide (Exclusive topology — the fallback when no stream target is live).
fn to_screen(x: f32, y: f32) -> POINT { fn to_screen(x: f32, y: f32) -> POINT {
// SAFETY: `GetSystemMetrics` takes a constant index and reads global metrics; no memory in. let (px, py) = crate::stream_target::map_normalized(x as f64, y as f64);
let (vx, vy, vw, vh) = unsafe { POINT { x: px, y: py }
(
GetSystemMetrics(SM_XVIRTUALSCREEN),
GetSystemMetrics(SM_YVIRTUALSCREEN),
GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1),
GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1),
)
};
POINT {
x: vx + (x.clamp(0.0, 1.0) * (vw - 1) as f32) as i32,
y: vy + (y.clamp(0.0, 1.0) * (vh - 1) as f32) as i32,
}
} }
/// An owned `HSYNTHETICPOINTERDEVICE` (destroyed exactly once on drop). /// An owned `HSYNTHETICPOINTERDEVICE` (destroyed exactly once on drop).
@@ -1,6 +1,6 @@
//! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of //! Windows input injection via `SendInput` (Win32 KeyboardAndMouse) — the Windows analogue of
//! [`super::wlr`]: absolute mouse normalized to the virtual desktop, relative mouse for games, //! [`super::wlr`]: absolute mouse mapped over the streamed output's rect
//! scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's //! ([`crate::stream_target`]), relative mouse for games, scancode keyboard, scroll, buttons. Survives UAC/lock desktop switches with Sunshine's
//! retry-on-failure model: the thread stays bound to its desktop and only reattaches //! retry-on-failure model: the thread stays bound to its desktop and only reattaches
//! (`OpenInputDesktop`/`SetThreadDesktop`) when `SendInput` reports a short write (the input //! (`OpenInputDesktop`/`SetThreadDesktop`) when `SendInput` reports a short write (the input
//! desktop switched) — no per-event reattach overhead. //! desktop switched) — no per-event reattach overhead.
@@ -32,14 +32,10 @@ use windows::Win32::UI::Input::KeyboardAndMouse::{
MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK, MOUSEEVENTF_MOVE, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_VIRTUALDESK,
MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY, MOUSEEVENTF_WHEEL, MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, VIRTUAL_KEY,
}; };
use windows::Win32::UI::WindowsAndMessaging::{ use windows::Win32::UI::WindowsAndMessaging::{GetForegroundWindow, GetWindowThreadProcessId};
GetForegroundWindow, GetSystemMetrics, GetWindowThreadProcessId, SM_CXVIRTUALSCREEN,
SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
};
use super::InputInjector; use super::InputInjector;
const ABS_MAX: f64 = 65535.0; // SendInput absolute coords are 0..65535 over the chosen surface.
const GENERIC_ALL: u32 = 0x1000_0000; const GENERIC_ALL: u32 = 0x1000_0000;
const XBUTTON1: u32 = 0x0001; const XBUTTON1: u32 = 0x0001;
const XBUTTON2: u32 = 0x0002; const XBUTTON2: u32 = 0x0002;
@@ -165,13 +161,16 @@ impl InputInjector for SendInputInjector {
if w == 0 || h == 0 { if w == 0 || h == 0 {
return Ok(()); // contract: drop zero extent return Ok(()); // contract: drop zero extent
} }
let (_vx, _vy, vw, vh) = virtual_desktop_rect(); // Client (0..w,0..h) → the STREAMED output's desktop rect
// One virtual output spanning the virtual desktop: map client (0..w,0..h) -> 0..65535. // ([`crate::stream_target`]; the whole virtual desktop only as fallback) →
// 0..65535 over the virtual desktop for MOUSEEVENTF_VIRTUALDESK. Mapping over
// the desktop alone is the Extend-topology offset bug the pen plane exposed
// (design/pen-tablet-input.md): correct only when the streamed display IS the
// whole desktop.
let cx = (event.x.clamp(0, w as i32)) as f64 / w as f64; let cx = (event.x.clamp(0, w as i32)) as f64 / w as f64;
let cy = (event.y.clamp(0, h as i32)) as f64 / h as f64; let cy = (event.y.clamp(0, h as i32)) as f64 / h as f64;
let ax = (cx * ABS_MAX).round() as i32; let px = crate::stream_target::map_normalized(cx, cy);
let ay = (cy * ABS_MAX).round() as i32; let (ax, ay) = crate::stream_target::desktop_px_to_virtualdesk(px);
let _ = (vw, vh); // virtual-desktop rect reserved for multi-output mapping
let mi = MOUSEINPUT { let mi = MOUSEINPUT {
dx: ax, dx: ax,
dy: ay, dy: ay,
@@ -378,19 +377,6 @@ fn key(ki: KEYBDINPUT) -> INPUT {
} }
} }
fn virtual_desktop_rect() -> (i32, i32, i32, i32) {
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
unsafe {
(
GetSystemMetrics(SM_XVIRTUALSCREEN),
GetSystemMetrics(SM_YVIRTUALSCREEN),
GetSystemMetrics(SM_CXVIRTUALSCREEN),
GetSystemMetrics(SM_CYVIRTUALSCREEN),
)
}
}
// VKs Windows wants flagged extended even when the scancode high bits aren't set: the editing // VKs Windows wants flagged extended even when the scancode high bits aren't set: the editing
// cluster (Ins/Del/Home/End/PgUp/PgDn = 0x21..0x28, 0x2D, 0x2E), the Win keys (0x5B/0x5C/0x5D), // cluster (Ins/Del/Home/End/PgUp/PgDn = 0x21..0x28, 0x2D, 0x2E), the Win keys (0x5B/0x5C/0x5D),
// RCtrl (0xA3), RAlt (0xA5), Pause (0x90). MAPVK_VK_TO_VSC_EX already encodes E0 for most; this is a // RCtrl (0xA3), RAlt (0xA5), Pause (0x90). MAPVK_VK_TO_VSC_EX already encodes E0 for most; this is a
@@ -0,0 +1,181 @@
//! The streamed display every absolute coordinate maps into (design/pen-tablet-input.md field
//! fix). Pen, touch, and absolute-mouse positions arrive normalized to the STREAMED output's
//! frame, but the injectors historically mapped them over the whole virtual desktop — correct
//! only when the virtual display is the sole active display (Exclusive topology, normalized to
//! origin). In Extend — a physical monitor kept on beside the virtual output, or an Exclusive
//! isolate degraded to the keep-physicals fallback — the streamed output sits at a non-zero
//! origin, so every sample landed shifted and mis-scaled (the pen exposed it first: a stylus is
//! strictly absolute, with no closed-loop correction onto the target like a cursor).
//!
//! The host publishes the streamed output's CCD target id at capture bring-up
//! ([`set_stream_target`]); the mapping sites resolve its CURRENT desktop rect through
//! [`pf_win_display::win_display::source_desktop_rect`] — the same resolver the cursor-readback
//! poller maps frames with, so the two directions always agree — TTL-cached because a
//! group-layout re-arrange moves a live output's origin mid-session. With no target set, or none
//! resolved yet, mapping falls back to the whole virtual desktop: the historical behavior, still
//! correct for Exclusive topology and the client-less devtest paths.
use std::sync::Mutex;
use std::time::{Duration, Instant};
use windows::Win32::UI::WindowsAndMessaging::{
GetSystemMetrics, SM_CXVIRTUALSCREEN, SM_CYVIRTUALSCREEN, SM_XVIRTUALSCREEN, SM_YVIRTUALSCREEN,
};
/// `(x, y, w, h)` in desktop coordinates, physical pixels (`source_desktop_rect` order).
type Rect = (i32, i32, i32, i32);
/// How long a resolved rect stays fresh: long enough that the CCD query cost vanishes at input
/// rates (pen samples + the 40 ms refresh threads), short enough that a mid-session layout move
/// (a parallel session joining the auto-row) is picked up within a blink.
const RECT_TTL: Duration = Duration::from_millis(250);
struct State {
target_id: Option<u32>,
rect: Option<Rect>,
queried: Option<Instant>,
}
static STATE: Mutex<State> = Mutex::new(State {
target_id: None,
rect: None,
queried: None,
});
/// Publish the streamed output (its CCD target id) that absolute input maps into. The host calls
/// this at capture bring-up; it is never cleared at teardown — a deactivated target simply stops
/// resolving (the last-known rect is kept, and nothing injects between sessions), and the next
/// session's bring-up re-targets. One slot per process: with parallel sessions the LAST bring-up
/// wins for every session's absolute input — per-session routing needs source-tagged input
/// events (parallel-displays plan) and the single slot is never worse than the historical
/// whole-desktop mapping.
pub fn set_stream_target(target_id: Option<u32>) {
let mut st = STATE.lock().unwrap();
if st.target_id != target_id {
tracing::info!(?target_id, "absolute-input stream target set");
st.target_id = target_id;
st.rect = None;
st.queried = None;
}
}
/// The streamed output's current desktop rect, TTL-cached. `None` = no target set / never
/// resolved (callers fall back to the whole virtual desktop).
fn stream_rect() -> Option<Rect> {
let mut st = STATE.lock().unwrap();
let target_id = st.target_id?;
let fresh = st.queried.is_some_and(|at| at.elapsed() < RECT_TTL);
if !fresh {
st.queried = Some(Instant::now());
// SAFETY: read-only QueryDisplayConfig over owned locals (`source_desktop_rect`'s
// contract — the same call the cursor-readback poller makes at spawn).
match unsafe { pf_win_display::win_display::source_desktop_rect(target_id) } {
Some(r) => {
if st.rect != Some(r) {
tracing::info!(target_id, rect = ?r, "stream-target desktop rect resolved");
}
st.rect = Some(r);
}
// Not an active path right now (teardown, or a topology commit in flight): keep the
// last-known rect — snapping mid-stroke to the whole-desktop mapping would visibly
// jump, and after teardown nothing injects until the next session re-targets.
None => {
if st.rect.is_some() {
tracing::debug!(
target_id,
"stream target not an active path — keeping last rect"
);
}
}
}
}
st.rect
}
/// Desktop-space pixel for a normalized `[0,1]²` coordinate over the streamed output's rect,
/// falling back to the whole virtual desktop when no stream target is live.
pub(crate) fn map_normalized(nx: f64, ny: f64) -> (i32, i32) {
map_into(stream_rect().unwrap_or_else(virtual_desktop_rect), nx, ny)
}
/// Pure mapping: `[0,1]²` over `(x, y, w, h)`, inclusive edges (1.0 lands on the last pixel).
fn map_into((x, y, w, h): Rect, nx: f64, ny: f64) -> (i32, i32) {
(
x + (nx.clamp(0.0, 1.0) * (w - 1).max(0) as f64).round() as i32,
y + (ny.clamp(0.0, 1.0) * (h - 1).max(0) as f64).round() as i32,
)
}
/// The virtual-desktop bounds `(x, y, w, h)` — the mapping fallback, and the surface
/// `MOUSEEVENTF_VIRTUALDESK` absolute coordinates normalize over.
pub(crate) fn virtual_desktop_rect() -> Rect {
// SAFETY: each `GetSystemMetrics` takes a single by-value `SYSTEM_METRICS_INDEX` constant and
// returns an `i32`; it dereferences no pointer and has no side effects — FFI-`unsafe` only.
unsafe {
(
GetSystemMetrics(SM_XVIRTUALSCREEN),
GetSystemMetrics(SM_YVIRTUALSCREEN),
GetSystemMetrics(SM_CXVIRTUALSCREEN).max(1),
GetSystemMetrics(SM_CYVIRTUALSCREEN).max(1),
)
}
}
/// A desktop-space pixel as the `MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_VIRTUALDESK` 0..65535
/// coordinate pair `SendInput` wants.
pub(crate) fn desktop_px_to_virtualdesk(px: (i32, i32)) -> (i32, i32) {
px_to_abs(virtual_desktop_rect(), px)
}
/// SendInput absolute coordinates span 0..65535 over the chosen surface.
const ABS_MAX: f64 = 65535.0;
/// Pure normalization: a desktop pixel inside `(x, y, w, h)` → 0..65535 over that surface.
fn px_to_abs((vx, vy, vw, vh): Rect, (px, py): (i32, i32)) -> (i32, i32) {
(
((px - vx) as f64 * ABS_MAX / (vw - 1).max(1) as f64).round() as i32,
((py - vy) as f64 * ABS_MAX / (vh - 1).max(1) as f64).round() as i32,
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The Extend-topology field bug: physical 1920x1080 at (0,0), streamed virtual 2560x1440
/// beside it at (1920,0) — samples must land inside the VIRTUAL output, not at the desktop
/// origin.
#[test]
fn maps_over_the_streamed_rect_not_the_desktop() {
let r = (1920, 0, 2560, 1440);
assert_eq!(map_into(r, 0.0, 0.0), (1920, 0));
assert_eq!(map_into(r, 1.0, 1.0), (1920 + 2559, 1439));
assert_eq!(map_into(r, 0.5, 0.5), (1920 + 1280, 720));
}
#[test]
fn clamps_out_of_range_and_handles_negative_origins() {
// An output placed LEFT of / ABOVE the primary has a negative desktop origin.
let r = (-2560, -100, 2560, 1440);
assert_eq!(map_into(r, 0.0, 0.0), (-2560, -100));
assert_eq!(map_into(r, 2.0, -1.0), (-2560 + 2559, -100));
}
#[test]
fn degenerate_rect_pins_to_its_origin() {
assert_eq!(map_into((10, 20, 0, 0), 0.7, 0.7), (10, 20));
}
/// The VIRTUALDESK round trip: win32k maps an absolute coordinate back to a pixel roughly as
/// `px = ax * vw / 65536` (floor) — edge pixels and the streamed output's origin must survive.
#[test]
fn virtualdesk_normalization_round_trips() {
let v = (0, 0, 4480, 1080);
assert_eq!(px_to_abs(v, (0, 0)), (0, 0));
assert_eq!(px_to_abs(v, (4479, 1079)), (65535, 65535));
let (ax, _) = px_to_abs(v, (1920, 0));
assert_eq!((ax as i64 * 4480 / 65536) as i32, 1920);
// Negative-origin desktops (a monitor left of the primary) still normalize from 0.
let v = (-2560, 0, 4480, 1440);
assert_eq!(px_to_abs(v, (-2560, 0)), (0, 0));
}
}
+10
View File
@@ -417,6 +417,16 @@ pub mod pen;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "inject/windows/pointer_windows.rs"] #[path = "inject/windows/pointer_windows.rs"]
pub mod pen; pub mod pen;
/// Windows: the streamed output's desktop rect that every absolute coordinate (pen, touch,
/// absolute mouse) maps into — published by the host at capture bring-up, resolved through the
/// CCD source rect (the cursor-readback poller's resolver, so both directions agree). Mapping
/// over the whole virtual desktop instead is the Extend-topology offset bug the pen exposed
/// (design/pen-tablet-input.md).
#[cfg(target_os = "windows")]
#[path = "inject/windows/stream_target.rs"]
pub mod stream_target;
#[cfg(target_os = "windows")]
pub use stream_target::set_stream_target;
/// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers; /// Stub — pen injection needs the Linux uinput tablet or Windows synthetic pointers;
/// `pen_supported()` is false here, so no host advertises the cap and no batches arrive. /// `pen_supported()` is false here, so no host advertises the cap and no batches arrive.
#[cfg(not(any(target_os = "linux", target_os = "windows")))] #[cfg(not(any(target_os = "linux", target_os = "windows")))]
+3
View File
@@ -45,6 +45,9 @@ futures-util = "0.3"
wayland-client = "0.31" wayland-client = "0.31"
wayland-scanner = "0.31" wayland-scanner = "0.31"
wayland-backend = "0.3" wayland-backend = "0.3"
# wayland-scanner emits `bitflags::bitflags!` for the KDE output-device protocol's bitfield enums
# (kde-output-device-v2 `capability`/`flags`); needs the crate in scope (kwin_output_mgmt.rs).
bitflags = "2"
[target.'cfg(target_os = "windows")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies]
# The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs). # The host<->driver wire contract for the pf-vdisplay IddCx backend (control IOCTLs + Pod structs).
@@ -0,0 +1,653 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="kde_output_device_v2">
<copyright><![CDATA[
SPDX-FileCopyrightText: 2008-2011 Kristian Høgsberg
SPDX-FileCopyrightText: 2010-2011 Intel Corporation
SPDX-FileCopyrightText: 2012-2013 Collabora, Ltd.
SPDX-FileCopyrightText: 2015 Sebastian Kügler <sebas@kde.org>
SPDX-FileCopyrightText: 2021 Méven Car <meven.car@enioka.com>
SPDX-License-Identifier: MIT-CMU
]]></copyright>
<interface name="kde_output_device_registry_v2" version="24">
<description summary="output devices">
This interface can be used to list output devices.
If this global is bound with a version less than 21, the unsupported_version
protocol error will be posted.
</description>
<enum name="error">
<description summary="kde_output_device_registry_v2 error values">
These errors can be emitted in response to some requests.
</description>
<entry name="unsupported_version" value="0"
summary="the registry was bound with an unsupported version"/>
</enum>
<event name="finished" type="destructor" since="21">
<description summary="no new output announcements">
This event is sent in response to the stop request. The compositor will
immediately destroy the object after sending this event.
</description>
</event>
<request name="stop" since="21">
<description summary="stop receiving updates">
This request indicates that the client no longer wants to receive new
output announcements. The compositor will send the
kde_output_device_registry_v2.finished event in response to this request.
The compositor may still send new output announcements after calling this
request until the kde_output_device_registry_v2.finished event is sent.
</description>
</request>
<event name="output" since="21">
<description summary="new available output">
This event is sent when a new output is connected or after binding this
global to list all available outputs.
</description>
<arg name="output" type="new_id" interface="kde_output_device_v2"/>
</event>
</interface>
<interface name="kde_output_device_v2" version="24">
<description summary="output configuration representation">
An output device describes a display device available to the compositor.
output_device is similar to wl_output, but focuses on output
configuration management.
A client can query all global output_device objects to enlist all
available display devices, even those that may currently not be
represented by the compositor as a wl_output.
The client sends configuration changes to the server through the
outputconfiguration interface, and the server applies the configuration
changes to the hardware and signals changes to the output devices
accordingly.
This object is published as global during start up for every available
display devices, or when one later becomes available, for example by
being hotplugged via a physical connector.
Warning! The protocol described in this file is a desktop environment
implementation detail. Regular clients must not use this protocol.
Backward incompatible changes may be added without bumping the major
version of the extension.
</description>
<enum name="subpixel">
<description summary="subpixel geometry information">
This enumeration describes how the physical pixels on an output are
laid out.
</description>
<entry name="unknown" value="0"/>
<entry name="none" value="1"/>
<entry name="horizontal_rgb" value="2"/>
<entry name="horizontal_bgr" value="3"/>
<entry name="vertical_rgb" value="4"/>
<entry name="vertical_bgr" value="5"/>
</enum>
<enum name="transform">
<description summary="transform from framebuffer to output">
This describes the transform, that a compositor will apply to a
surface to compensate for the rotation or mirroring of an
output device.
The flipped values correspond to an initial flip around a
vertical axis followed by rotation.
The purpose is mainly to allow clients to render accordingly and
tell the compositor, so that for fullscreen surfaces, the
compositor is still able to scan out directly client surfaces.
</description>
<entry name="normal" value="0"/>
<entry name="90" value="1"/>
<entry name="180" value="2"/>
<entry name="270" value="3"/>
<entry name="flipped" value="4"/>
<entry name="flipped_90" value="5"/>
<entry name="flipped_180" value="6"/>
<entry name="flipped_270" value="7"/>
</enum>
<event name="geometry">
<description summary="geometric properties of the output">
The geometry event describes geometric properties of the output.
The event is sent when binding to the output object and whenever
any of the properties change.
</description>
<arg name="x" type="int"
summary="x position within the global compositor space"/>
<arg name="y" type="int"
summary="y position within the global compositor space"/>
<arg name="physical_width" type="int"
summary="width in millimeters of the output"/>
<arg name="physical_height" type="int"
summary="height in millimeters of the output"/>
<arg name="subpixel" type="int"
summary="subpixel orientation of the output"/>
<arg name="make" type="string"
summary="textual description of the manufacturer"/>
<arg name="model" type="string"
summary="textual description of the model"/>
<arg name="transform" type="int"
summary="transform that maps framebuffer to output"/>
</event>
<event name="current_mode">
<description summary="current mode">
This event describes the mode currently in use for this head. It is only
sent if the output is enabled.
</description>
<arg name="mode" type="object" interface="kde_output_device_mode_v2"/>
</event>
<event name="mode">
<description summary="advertise available output modes and current one">
The mode event describes an available mode for the output.
When the client binds to the output_device object, the server sends this
event once for every available mode the output_device can be operated by.
There will always be at least one event sent out on initial binding,
which represents the current mode.
Later if an output changes, its mode event is sent again for the
eventual added modes and lastly the current mode. In other words, the
current mode is always represented by the latest event sent with the current
flag set.
The size of a mode is given in physical hardware units of the output device.
This is not necessarily the same as the output size in the global compositor
space. For instance, the output may be scaled, as described in
kde_output_device_v2.scale, or transformed, as described in
kde_output_device_v2.transform.
</description>
<arg name="mode" type="new_id" interface="kde_output_device_mode_v2"/>
</event>
<event name="done">
<description summary="sent all information about output">
This event is sent after all other properties have been
sent on binding to the output object as well as after any
other output property change have been applied later on.
This allows to see changes to the output properties as atomic,
even if multiple events successively announce them.
</description>
</event>
<event name="scale">
<description summary="output scaling properties">
This event contains scaling geometry information
that is not in the geometry event. It may be sent after
binding the output object or if the output scale changes
later. If it is not sent, the client should assume a
scale of 1.
A scale larger than 1 means that the compositor will
automatically scale surface buffers by this amount
when rendering. This is used for high resolution
displays where applications rendering at the native
resolution would be too small to be legible.
It is intended that scaling aware clients track the
current output of a surface, and if it is on a scaled
output it should use wl_surface.set_buffer_scale with
the scale of the output. That way the compositor can
avoid scaling the surface, and the client can supply
a higher detail image.
</description>
<arg name="factor" type="fixed" summary="scaling factor of output"/>
</event>
<event name="edid">
<description summary="advertise EDID data for the output">
The edid event encapsulates the EDID data for the outputdevice.
The event is sent when binding to the output object. The EDID
data may be empty, in which case this event is sent anyway.
If the EDID information is empty, you can fall back to the name
et al. properties of the outputdevice.
</description>
<arg name="raw" type="string" summary="base64-encoded EDID string"/>
</event>
<event name="enabled">
<description summary="output is enabled or disabled">
The enabled event notifies whether this output is currently
enabled and used for displaying content by the server.
The event is sent when binding to the output object and
whenever later on an output changes its state by becoming
enabled or disabled.
</description>
<arg name="enabled" type="int" summary="output enabled state"/>
</event>
<event name="uuid">
<description summary="A unique id for this outputdevice">
The uuid can be used to identify the output. It's controlled by
the server entirely. The server should make sure the uuid is
persistent across restarts. An empty uuid is considered invalid.
</description>
<arg name="uuid" type="string" summary="output devices ID"/>
</event>
<event name="serial_number">
<description summary="Serial Number">
Serial ID of the monitor, sent on startup before the first done event.
</description>
<arg name="serialNumber" type="string"
summary="textual representation of serial number"/>
</event>
<event name="eisa_id">
<description summary="EISA ID">
EISA ID of the monitor, sent on startup before the first done event.
</description>
<arg name="eisaId" type="string"
summary="textual representation of EISA identifier"/>
</event>
<enum name="capability" bitfield="true">
<description summary="describes capabilities of the outputdevice">
Describes what capabilities this device has.
</description>
<entry name="overscan" value="0x1"
summary="if this output_device can use overscan"/>
<entry name="vrr" value="0x2"
summary="if this outputdevice supports variable refresh rate"/>
<entry name="rgb_range" value="0x4"
summary="if setting the rgb range is possible"/>
<entry name="high_dynamic_range" value="0x8" since="3"
summary="if this outputdevice supports high dynamic range"/>
<entry name="wide_color_gamut" value="0x10" since="3"
summary="if this outputdevice supports a wide color gamut"/>
<entry name="auto_rotate" value="0x20" since="4"
summary="if this outputdevice supports autorotation"/>
<entry name="icc_profile" value="0x40" since="5"
summary="if this outputdevice supports icc profiles"/>
<entry name="brightness" value="0x80" since="9"
summary="if this outputdevice supports the brightness setting"/>
<entry name="built_in_color" value="0x100" since="12"
summary="if this outputdevice supports the built-in color profile"/>
<entry name="ddc_ci" value="0x200" since="14"
summary="if this outputdevice supports DDC/CI"/>
<entry name="max_bits_per_color" value="0x400" since="15"
summary="if this outputdevice supports setting max bpc"/>
<entry name="edr" value="0x800" since="16"
summary="if this outputdevice supports EDR"/>
<entry name="sharpness" value="0x1000" since="17"
summary="if this outputdevice supports the sharpness setting"/>
<entry name="custom_modes" value="0x2000" since="18"
summary="if this outputdevice supports custom modes"/>
<entry name="auto_brightness" value = "0x4000" since="19"/>
<entry name="hdr_icc_profile" value="0x8000" since="22"
summary="if this outputdevice supports HDR ICC profiles"/>
<entry name="abm_level" value="0x10000" since="23"
summary="if this outputdevice supports the abm level setting"/>
</enum>
<event name="capabilities">
<description summary="capability flags">
What capabilities this device has, sent on startup before the first
done event.
</description>
<arg name="flags" type="uint" enum="capability"/>
</event>
<event name="overscan">
<description summary="overscan">
Overscan value of the monitor in percent, sent on startup before the
first done event.
</description>
<arg name="overscan" type="uint"
summary="amount of overscan of the monitor"/>
</event>
<enum name="vrr_policy">
<description summary="describes vrr policy">
Describes when the compositor may employ variable refresh rate
</description>
<entry name="never" value="0"/>
<entry name="always" value="1"/>
<entry name="automatic" value="2"/>
</enum>
<event name="vrr_policy">
<description summary="Variable Refresh Rate Policy">
What policy the compositor will employ regarding its use of variable
refresh rate.
</description>
<arg name="vrr_policy" type="uint" enum="vrr_policy"/>
</event>
<enum name="rgb_range">
<description summary="describes RGB range policy">
Whether full or limited color range should be used
</description>
<entry name="automatic" value="0"/>
<entry name="full" value="1"/>
<entry name="limited" value="2"/>
</enum>
<event name="rgb_range">
<description summary="RGB range">
What rgb range the compositor is using for this output
</description>
<arg name="rgb_range" type="uint" enum="rgb_range"/>
</event>
<event name="name" since="2">
<description summary="Output's name">
Name of the output, it's useful to cross-reference to an zxdg_output_v1 and ultimately QScreen
</description>
<arg name="name" type="string"/>
</event>
<event name="high_dynamic_range" since="3">
<description summary="if HDR is enabled">
Whether or not high dynamic range is enabled for this output
</description>
<arg name="hdr_enabled" type="uint" summary="1 if enabled, 0 if disabled"/>
</event>
<event name="sdr_brightness" since="3">
<description summary="the brightness of sdr if hdr is enabled">
If high dynamic range is used, this value defines the brightness in nits for content
that's in standard dynamic range format. Note that while the value is in nits, that
doesn't necessarily translate to the same brightness on the screen.
</description>
<arg name="sdr_brightness" type="uint"/>
</event>
<event name="wide_color_gamut" since="3">
<description summary="if WCG is enabled">
Whether or not the use of a wide color gamut is enabled for this output
</description>
<arg name="wcg_enabled" type="uint" summary="1 if enabled, 0 if disabled"/>
</event>
<enum name="auto_rotate_policy">
<description summary="describes when auto rotate should be used"/>
<entry name="never" value="0"/>
<entry name="in_tablet_mode" value="1"/>
<entry name="always" value="2"/>
</enum>
<event name="auto_rotate_policy" since="4">
<description summary="describes when auto rotate is used"/>
<arg name="policy" type="uint" enum="auto_rotate_policy"/>
</event>
<event name="icc_profile_path" since="5">
<description summary="describes the path to the ICC profile used in SDR mode"/>
<arg name="profile_path" type="string"/>
</event>
<event name="brightness_metadata" since="6">
<description summary="metadata about the screen's brightness limits"/>
<arg name="max_peak_brightness" type="uint" summary="in nits"/>
<arg name="max_frame_average_brightness" type="uint" summary="in nits"/>
<arg name="min_brightness" type="uint" summary="in 0.0001 nits"/>
</event>
<event name="brightness_overrides" since="6">
<description summary="overrides for the screen's brightness limits"/>
<arg name="max_peak_brightness" type="int" summary="-1 for no override, positive values are the brightness in nits"/>
<arg name="max_average_brightness" type="int" summary="-1 for no override, positive values are the brightness in nits"/>
<arg name="min_brightness" type="int" summary="-1 for no override, positive values are the brightness in 0.0001 nits"/>
</event>
<event name="sdr_gamut_wideness" since="6">
<description summary="describes which gamut is assumed for sRGB applications">
This can be used to provide the colors users assume sRGB applications should have based on the
default experience on many modern sRGB screens.
</description>
<arg name="gamut_wideness" type="uint" summary="0 means rec.709 primaries, 10000 means native primaries"/>
</event>
<enum name="color_profile_source" since="7">
<description summary="which source the compositor should use for the color profile on an output"/>
<entry name="sRGB" value="0"/>
<entry name="ICC" value="1"/>
<entry name="EDID" value="2"/>
</enum>
<event name="color_profile_source" since="7">
<description summary="describes which source the compositor uses for the color profile on an output in SDR mode"/>
<arg name="source" type="uint" enum="color_profile_source"/>
</event>
<event name="brightness" since="8">
<description summary="brightness multiplier">
This is the brightness modifier of the output. It doesn't specify
any absolute values, but is merely a multiplier on top of other
brightness values, like sdr_brightness and brightness_metadata.
0 is the minimum brightness (not completely dark) and 10000 is
the maximum brightness.
This is currently only supported / meaningful while HDR is active.
</description>
<arg name="brightness" type="uint" summary="brightness in 0-10000"/>
</event>
<enum name="color_power_tradeoff">
<description summary="tradeoff between power and accuracy">
The compositor can do a lot of things that trade between
performance, power and color accuracy. This setting describes
a high level preference from the user about in which direction
that tradeoff should be made.
</description>
<entry name="efficiency" value="0" summary="prefer efficiency and performance"/>
<entry name="accuracy" value="1" summary="prefer accuracy"/>
</enum>
<event name="color_power_tradeoff" since="10">
<description summary="the preferred color/power tradeoff"/>
<arg name="preference" type="uint" enum="color_power_tradeoff"/>
</event>
<event name="dimming" since="11">
<description summary="dimming multiplier">
This is the dimming multiplier of the output. This is similar to
the brightness setting, except it's meant to be a temporary setting
only, not persistent and may be implemented differently depending
on the display.
0 is the minimum dimming factor (not completely dark) and 10000
means the output is not dimmed.
</description>
<arg name="multiplier" type="uint" summary="multiplier in 0-10000"/>
</event>
<event name="replication_source" since="13">
<description summary="source output for mirroring"/>
<arg name="source" type="string" summary="uuid of the source output"/>
</event>
<event name="ddc_ci_allowed" since="14">
<description summary="if DDC/CI should be used to control brightness etc.">
If the ddc_ci capability is present, this determines if settings
such as brightness, contrast or others should be set using DDC/CI.
</description>
<arg name="allowed" type="uint" summary="1 if allowed, 0 if disabled"/>
</event>
<event name="max_bits_per_color" since="15">
<description summary="override max bpc">
This limits the amount of bits per color that are sent to the display.
</description>
<arg name="max_bpc" type="uint" summary="0 for the default / automatic"/>
</event>
<event name="max_bits_per_color_range" since="15">
<description summary="range of max bits per color value"/>
<arg name="min_value" type="uint" summary="the minimum supported by the driver"/>
<arg name="max_value" type="uint" summary="the maximum supported by the driver"/>
</event>
<event name="automatic_max_bits_per_color_limit" since="15">
<description summary="if and to what value automatic max bpc is limited"/>
<arg name="max_bpc_limit" type="uint"
summary="which value automatic bpc gets limited to. 0 if not limited"/>
</event>
<enum name="edr_policy" since="16">
<description summary="when the compositor may make use of EDR"/>
<entry name="never" value="0"/>
<entry name="always" value="1"/>
</enum>
<event name="edr_policy" since="16">
<description summary="when the compositor may apply EDR">
When EDR is enabled, the compositor may increase the backlight beyond
the user-specified setting, in order to present HDR content on displays
without native HDR support.
This will usually result in better visuals, but also increases battery
usage.
</description>
<arg name="policy" type="uint" enum="edr_policy"/>
</event>
<event name="sharpness" since="17">
<description summary="sharpness strength">
This is the sharpness modifier of the output.
0 is sharpness disabled and 10000 is the maximum sharpness
</description>
<arg name="sharpness" type="uint" summary="sharpness in 0-10000"/>
</event>
<event name="priority" since="18">
<description summary="output priority">
Describes the position of the output in the output order list,
with lower values being earlier in the list. There's no specific
value the list has to start at, this value is only used in sorting
outputs.
Note that the output order protocol is not sufficient for this,
as an output may not be in the output order if it's disabled or
mirroring another screen.
</description>
<arg name="priority" type="uint" summary="priority"/>
</event>
<event name="auto_brightness" since="20">
<description summary="whether or not automatic brightness is enabled"/>
<arg name="enabled" type="uint" summary="1 for enabled, 0 for disabled"/>
</event>
<request name="release" type="destructor" since="21">
<description summary="destroy the output device">
This notifies the compositor that the client no longer wishes to use
the kde_output_device_v2 object.
</description>
</request>
<event name="removed" since="21">
<description summary="the output has been removed">
This event is sent when the output device is disconnected and no new
updates will be sent. The client should call the kde_output_device_v2.release
request after receiving this event.
</description>
</event>
<event name="hdr_icc_profile_path" since="22">
<description summary="describes the path to the ICC profile used in HDR mode"/>
<arg name="profile_path" type="string"/>
</event>
<event name="hdr_color_profile_source" since="22">
<description summary="describes which source the compositor uses for the color profile on an output in HDR mode"/>
<arg name="source" type="uint" enum="color_profile_source"/>
</event>
<event name="abm_level" since="23">
<description summary="allowed level of adaptive backlight modulation">
Adaptive backlight modulation is a feature that reduces the backlight
and increases contrast of colors on the screen to improve power usage.
</description>
<arg name="level" type="uint" summary="0 is off, 4 is the maximum level"/>
</event>
</interface>
<interface name="kde_output_device_mode_v2" version="24">
<description summary="output mode">
This object describes an output mode.
Some heads don't support output modes, in which case modes won't be
advertised.
Properties sent via this interface are applied atomically via the
kde_output_device.done event. No guarantees are made regarding the order
in which properties are sent.
</description>
<event name="size">
<description summary="mode size">
This event describes the mode size. The size is given in physical
hardware units of the output device. This is not necessarily the same as
the output size in the global compositor space. For instance, the output
may be scaled or transformed.
</description>
<arg name="width" type="int" summary="width of the mode in hardware units"/>
<arg name="height" type="int" summary="height of the mode in hardware units"/>
</event>
<event name="refresh">
<description summary="mode refresh rate">
This event describes the mode's fixed vertical refresh rate. It is only
sent if the mode has a fixed refresh rate.
</description>
<arg name="refresh" type="int" summary="vertical refresh rate in mHz"/>
</event>
<event name="preferred">
<description summary="mode is preferred">
This event advertises this mode as preferred.
</description>
</event>
<event name="removed">
<description summary="the mode has been destroyed">
The compositor will destroy the object immediately after sending this
event, so it will become invalid and the client should release any
resources associated with it.
</description>
</event>
<enum name="flags">
<description summary="mode flags"/>
<entry name="custom" value="0x1"/>
<entry name="reduced_blanking" value="0x2" deprecated-since="24"/>
</enum>
<event name="flags" since="19">
<description summary="mode flags">
This event describes the mode's flags.
</description>
<arg name="flags" type="uint" enum="flags"/>
</event>
<event name="cvt" since="24">
<description summary="cvt timings">
This event describes the CVT timings associated with the mode.
If an output mode has no CVT timings, this event will not be sent.
</description>
<arg name="dot_clock" type="uint" summary="pixel clock in kHz"/>
<arg name="hdisplay" type="uint" summary="horizontal display size"/>
<arg name="hsync_start" type="uint" summary="horizontal sync start"/>
<arg name="hsync_end" type="uint" summary="horizontal sync end"/>
<arg name="htotal" type="uint" summary="horizontal total size"/>
<arg name="hskew" type="uint" summary="horizontal skew"/>
<arg name="vdisplay" type="uint" summary="vertical display size"/>
<arg name="vsync_start" type="uint" summary="vertical sync start"/>
<arg name="vsync_end" type="uint" summary="vertical sync end"/>
<arg name="vtotal" type="uint" summary="vertical total size"/>
<arg name="vscan" type="uint" summary="vertical scan"/>
<arg name="flags" type="uint" summary="flags, see DRM_MODE_FLAG_*"/>
</event>
</interface>
</protocol>
@@ -0,0 +1,539 @@
<?xml version="1.0" encoding="UTF-8"?>
<protocol name="kde_output_management_v2">
<copyright><![CDATA[
SPDX-FileCopyrightText: 2008-2011 Kristian Høgsberg
SPDX-FileCopyrightText: 2010-2011 Intel Corporation
SPDX-FileCopyrightText: 2012-2013 Collabora, Ltd.
SPDX-FileCopyrightText: 2015 Sebastian Kügler <sebas@kde.org>
SPDX-FileCopyrightText: 2021 Méven Car <meven.car@enioka.com>
SPDX-FileCopyrightText: 2023 Xaver Hugl <xaver.hugl@kde.org>
SPDX-License-Identifier: MIT-CMU
]]></copyright>
<interface name="kde_output_management_v2" version="22">
<description summary="configuration of server outputs through clients">
This interface enables clients to set properties of output devices for screen
configuration purposes via the server. To this end output devices are referenced
by global kde_output_device_v2 objects.
outputmanagement (wl_global)
--------------------------
request:
* create_configuration -> outputconfiguration (wl_resource)
outputconfiguration (wl_resource)
--------------------------
requests:
* enable(outputdevice, bool)
* mode(outputdevice, mode)
* transformation(outputdevice, flag)
* position(outputdevice, x, y)
* apply
events:
* applied
* failed
The server registers one outputmanagement object as a global object. In order
to configure outputs a client requests create_configuration, which provides a
resource referencing an outputconfiguration for one-time configuration. That
way the server knows which requests belong together and can group them by that.
On the outputconfiguration object the client calls for each output whether the
output should be enabled, which mode should be set (by referencing the mode from
the list of announced modes) and the output's global position. Once all outputs
are configured that way, the client calls apply.
At that point and not earlier the server should try to apply the configuration.
If this succeeds the server emits the applied signal, otherwise the failed
signal, such that the configuring client is noticed about the success of its
configuration request.
Through this design the interface enables atomic output configuration changes if
internally supported by the server.
Warning! The protocol described in this file is a desktop environment implementation
detail. Regular clients must not use this protocol. Backward incompatible
changes may be added without bumping the major version of the extension.
</description>
<request name="create_configuration">
<description summary="provide outputconfiguration object for configuring outputs">
Request an outputconfiguration object through which the client can configure
output devices.
</description>
<arg name="id" type="new_id" interface="kde_output_configuration_v2"/>
</request>
<request name="create_mode_list">
<description summary="create a list of custom modes">
For details, see the description of kde_mode_list_v2 and
kde_output_configuration_v2.set_custom_modes.
</description>
<arg name="id" type="new_id" interface="kde_mode_list_v2"/>
</request>
</interface>
<interface name="kde_output_configuration_v2" version="22">
<description summary="configure single output devices">
outputconfiguration is a client-specific resource that can be used to ask
the server to apply changes to available output devices.
The client receives a list of output devices from the registry. When it wants
to apply new settings, it creates a configuration object from the
outputmanagement global, writes changes through this object's enable, scale,
transform and mode calls. It then asks the server to apply these settings in
an atomic fashion, for example through Linux' DRM interface.
The server signals back whether the new settings have applied successfully
or failed to apply. outputdevice objects are updated after the changes have been
applied to the hardware and before the server side sends the applied event.
</description>
<enum name="error">
<description summary="kde_output_configuration_v2 error values">
These error can be emitted in response to kde_output_configuration_v2 requests.
</description>
<entry name="already_applied" value="0" summary="the config is already applied"/>
</enum>
<request name="enable">
<description summary="enable or disable an output">
Mark the output as enabled or disabled.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice to be en- or disabled"/>
<arg name="enable" type="int" summary="1 to enable or 0 to disable this output"/>
</request>
<request name="mode">
<description summary="switch output-device to mode">
Sets the mode for a given output.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this mode change applies to"/>
<arg name="mode" type="object" interface="kde_output_device_mode_v2" summary="the mode to apply"/>
</request>
<request name="transform">
<description summary="transform output-device">
Sets the transformation for a given output.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this transformation change applies to"/>
<arg name="transform" type="int" summary="transform enum"/>
</request>
<request name="position">
<description summary="position output in global space">
Sets the position for this output device. (x,y) describe the top-left corner
of the output in global space, whereby the origin (0,0) of the global space
has to be aligned with the top-left corner of the most left and in case this
does not define a single one the top output.
There may be no gaps or overlaps between outputs, i.e. the outputs are
stacked horizontally, vertically, or both on each other.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this position applies to"/>
<arg name="x" type="int" summary="position on the x-axis"/>
<arg name="y" type="int" summary="position on the y-axis"/>
</request>
<request name="scale">
<description summary="set scaling factor of this output">
Sets the scaling factor for this output device.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this scale change applies to"/>
<arg name="scale" type="fixed" summary="scaling factor"/>
</request>
<request name="apply">
<description summary="apply configuration changes to all output devices">
Asks the server to apply property changes requested through this outputconfiguration
object to all outputs on the server side.
The output configuration can be applied only once. The already_applied protocol error
will be posted if the apply request is called the second time.
</description>
</request>
<event name="applied">
<description summary="configuration changes have been applied">
Sent after the server has successfully applied the changes.
.
</description>
</event>
<event name="failed">
<description summary="configuration changes failed to apply">
Sent if the server rejects the changes or failed to apply them.
</description>
</event>
<request name="destroy" type="destructor">
<description summary="release the outputconfiguration object"/>
</request>
<request name="overscan">
<description summary="set overscan value">
Set the overscan value of this output device with a value in percent.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice overscan applies to"/>
<arg name="overscan" type="uint" summary="overscan value"/>
</request>
<enum name="vrr_policy">
<description summary="describes vrr policy">
Describes when the compositor may employ variable refresh rate
</description>
<entry name="never" value="0"/>
<entry name="always" value="1"/>
<entry name="automatic" value="2"/>
</enum>
<request name="set_vrr_policy">
<description summary="set the VRR policy">
Set what policy the compositor should employ regarding its use of
variable refresh rate.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this VRR policy applies to"/>
<arg name="policy" type="uint" enum="vrr_policy" summary="the vrr policy to apply"/>
</request>
<enum name="rgb_range">
<description summary="describes RGB range policy">
Whether this output should use full or limited rgb.
</description>
<entry name="automatic" value="0"/>
<entry name="full" value="1"/>
<entry name="limited" value="2"/>
</enum>
<request name="set_rgb_range">
<description summary="RGB range">
Whether full or limited color range should be used
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice the rgb range applies to"/>
<arg name="rgb_range" type="uint" enum="rgb_range"/>
</request>
<request name="set_primary_output" since="2">
<description summary="Select which primary output to use" />
<arg name="output" type="object" interface="kde_output_device_v2" allow-null="false"/>
</request>
<request name="set_priority" since="3">
<description summary="Set the order of outputs">
Set the position of the output in the output order list, with lower values
being earlier in the list. There's no specific value the list has to start
at, this value is only used in sorting outputs.
The order of outputs can be used to assign desktop environment components
to a specific screen, see kde_output_order_v1 and kde-output-device-v2 for
details. Note that for consistent behavior, the priority value needs to be
unique among all enabled outputs.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice the index applies to" />
<arg name="priority" type="uint" summary="the priority of the output" />
</request>
<request name="set_high_dynamic_range" since="4">
<description summary="change if HDR should be enabled">
Sets whether or not the output should be set to HDR mode.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="enable_hdr" type="uint" summary="1 to enable, 0 to disable hdr"/>
</request>
<request name="set_sdr_brightness" since="4">
<description summary="set the brightness for sdr content">
Sets the brightness of standard dynamic range content in nits. Only has an effect while the output is in HDR mode.
Note that while the value is in nits, that doesn't necessarily translate to the same brightness on the screen.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="sdr_brightness" type="uint"/>
</request>
<request name="set_wide_color_gamut" since="4">
<description summary="change if a wide color gamut should be used">
Whether or not the output should use a wide color gamut
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="enable_wcg" type="uint" summary="1 to enable, 0 to disable wcg"/>
</request>
<enum name="auto_rotate_policy">
<description summary="describes when auto rotate should be used"/>
<entry name="never" value="0"/>
<entry name="in_tablet_mode" value="1"/>
<entry name="always" value="2"/>
</enum>
<request name="set_auto_rotate_policy" since="5">
<description summary="change when auto rotate should be used"/>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="policy" type="uint" enum="auto_rotate_policy"/>
</request>
<request name="set_icc_profile_path" since="6">
<description summary="change the used icc profile for SDR mode"/>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="profile_path" type="string"/>
</request>
<request name="set_brightness_overrides" since="7">
<description summary="override metadata about the screen's brightness limits"/>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="max_peak_brightness" type="int" summary="-1 for not overriding, or positive values in nits"/>
<arg name="max_frame_average_brightness" type="int" summary="-1 for not overriding, or positive values in nits"/>
<arg name="min_brightness" type="int" summary="-1 for not overriding, or positive values in 0.0001 nits"/>
</request>
<request name="set_sdr_gamut_wideness" since="7">
<description summary="describes which gamut is assumed for sRGB applications">
This can be used to provide the colors users assume sRGB applications should have based on the
default experience on many modern sRGB screens.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="gamut_wideness" type="uint" summary="0 means rec.709 primaries, 10000 means native primaries"/>
</request>
<enum name="color_profile_source" since="7">
<description summary="which source the compositor should use for the color profile on an output"/>
<entry name="sRGB" value="0"/>
<entry name="ICC" value="1"/>
<entry name="EDID" value="2"/>
</enum>
<request name="set_color_profile_source" since="8">
<description summary="which source the compositor should use for the color profile on an output in SDR mode"/>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="color_profile_source" type="uint" enum="color_profile_source" summary="the color profile source"/>
</request>
<request name="set_brightness" since="9">
<description summary="brightness multiplier">
Set the brightness modifier of the output. It doesn't specify
any absolute values, but is merely a multiplier on top of other
brightness values, like sdr_brightness and brightness_metadata.
0 is the minimum brightness (not completely dark) and 10000 is
the maximum brightness.
This is supported while HDR is active in versions 8 and below,
or when the device supports the "brightness" capability in
versions 9 and above.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="brightness" type="uint" summary="brightness in 0-10000"/>
</request>
<enum name="color_power_tradeoff">
<description summary="tradeoff between power and accuracy">
The compositor can do a lot of things that trade between
performance, power and color accuracy. This setting describes
a high level preference from the user about in which direction
that tradeoff should be made.
</description>
<entry name="efficiency" value="0" summary="prefer efficiency and performance"/>
<entry name="accuracy" value="1" summary="prefer accuracy"/>
</enum>
<request name="set_color_power_tradeoff" since="10">
<description summary="set the preferred color/power tradeoff"/>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="preference" type="uint" enum="color_power_tradeoff"/>
</request>
<request name="set_dimming" since="11">
<description summary="dimming multiplier">
Set the dimming multiplier of the output. This is similar to the
brightness setting, except it's meant to be a temporary setting
only, not persistent and may be implemented differently depending
on the display.
0 is the minimum dimming factor (not completely dark) and 10000
means the output is not dimmed.
This is supported only when the "brightness" capability is
also supported.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="multiplier" type="uint" summary="multiplier in 0-10000"/>
</request>
<event name="failure_reason" since="12">
<description summary="reason for failure">
Describes why applying the output configuration failed. Is only
sent before the failure event.
</description>
<arg name="reason" type="string" summary="reason for failure"/>
</event>
<request name="set_replication_source" since="13">
<description summary="source output for mirroring">
Set the source output that the outputdevice should mirror its
viewport from.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="source" type="string" summary="uuid of the source output"/>
</request>
<request name="set_ddc_ci_allowed" since="14">
<description summary="if DDC/CI should be used to control brightness etc."/>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="allowed" type="uint" summary="1 if allowed, 0 if disabled"/>
</request>
<request name="set_max_bits_per_color" since="15">
<description summary="override the max bpc">
This limits the amount of bits per color that are sent to the display.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="max_bpc" type="uint" summary="0 for the default / automatic"/>
</request>
<enum name="edr_policy" since="16">
<description summary="when the compositor may make use of EDR"/>
<entry name="never" value="0"/>
<entry name="always" value="1"/>
</enum>
<request name="set_edr_policy" since="16">
<description summary="set when the compositor may apply EDR">
When EDR is enabled, the compositor may increase the backlight beyond
the user-specified setting, in order to present HDR content on displays
without native HDR support.
This will usually result in better visuals, but also increases battery
usage.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="policy" type="uint" enum="edr_policy"/>
</request>
<request name="set_sharpness" since="17">
<description summary="sharpness strength">
This is the sharpness modifier of the output.
0 is sharpness disabled and 10000 is the maximum sharpness
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="sharpness" type="uint" summary="sharpness in 0-10000"/>
</request>
<request name="set_custom_modes" since="18">
<description summary="set the custom mode list">
Set the list of custom modes for this output. The compositor
will in response generate the requested modes and add them to
the output (or delete ones no longer in the list).
This can be useful for overclocking displays, or for working
around broken EDIDs.
Note that there is no guarantee for any custom mode to
actually work, or even to leave the display undamaged (in the
case of CRTs). It's entirely the responsibility of the user
to ensure each added mode is the right one for their display.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="modes" type="object" interface="kde_mode_list_v2"/>
</request>
<request name="set_auto_brightness" since="19">
<description summary="whether or not automatic brightness is enabled"/>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="enabled" type="uint" summary="1 for enabled, 0 for disabled"/>
</request>
<request name="set_hdr_icc_profile_path" since="20">
<description summary="change the used icc profile for HDR mode"/>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="profile_path" type="string"/>
</request>
<request name="set_hdr_color_profile_source" since="20">
<description summary="which source the compositor should use for the color profile on an output in HDR mode"/>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="color_profile_source" type="uint" enum="color_profile_source" summary="the color profile source"/>
</request>
<request name="set_abm_level" since="21">
<description summary="set the allowed level of adaptive backlight modulation">
Adaptive backlight modulation is a feature that reduces the backlight
and increases contrast of colors on the screen to improve power usage.
</description>
<arg name="outputdevice" type="object" interface="kde_output_device_v2" summary="outputdevice this setting applies to"/>
<arg name="level" type="uint" summary="0 is off, 4 is the maximum level"/>
</request>
</interface>
<interface name="kde_mode_list_v2" version="22">
<description summary="a list of custom modes">
This list is populated by first setting each relevant property,
and then calling add_mode to add a mode with these properties.
One would for example call
- set_resolution
- set_refresh_rate
- set_reduced_blanking
- add_mode
add_mode does not reset the properties that were previously set,
they are valid until the object is destroyed.
The compositor may additionally have sensible defaults for some
properties like reduced_blanking, but for consistent results,
it's best to always set each known property every time.
One can also specify custom modes with CVT timings, for example
- add_cvt
- add_cvt
The parameters resolution and refresh rate are required, if they
are not set, the missing_parameters error will be emitted.
</description>
<enum name="error">
<description summary="kde_mode_list_v2 error values">
These errors can be emitted in response to add_mode requests.
</description>
<entry name="missing_parameters" value="0" summary="a required parameter wasn't set"/>
</enum>
<request name="destroy" type="destructor">
<description summary="destroy the mode list object"/>
</request>
<request name="add_mode">
<description summary="Add the current mode configuration to the list"/>
</request>
<request name="set_resolution">
<arg name="width" type="uint"/>
<arg name="height" type="uint"/>
</request>
<request name="set_refresh_rate">
<arg name="rate" type="uint" summary="in milliHz"/>
</request>
<request name="set_reduced_blanking">
<description summary="whether or not the mode should have reduced blanking">
Reduced blanking is an optimization that can reduce bandwidth / timing
requirements for a display mode by reducing the time vblank takes.
As not all displays support it, it may be desired to still turn it off
though (like with CRTs, where full blanking is required).
</description>
<arg name="reduced" type="uint"
summary="1 for reduced blanking, 0 for normal vblank duration"/>
</request>
<request name="add_cvt" since="22">
<description summary="new mode with CVT timings">
Adds a new mode with the specified CVT timings.
</description>
<arg name="dot_clock" type="uint" summary="pixel clock in kHz"/>
<arg name="hdisplay" type="uint" summary="horizontal display size"/>
<arg name="hsync_start" type="uint" summary="horizontal sync start"/>
<arg name="hsync_end" type="uint" summary="horizontal sync end"/>
<arg name="htotal" type="uint" summary="horizontal total size"/>
<arg name="hskew" type="uint" summary="horizontal skew"/>
<arg name="vdisplay" type="uint" summary="vertical display size"/>
<arg name="vsync_start" type="uint" summary="vertical sync start"/>
<arg name="vsync_end" type="uint" summary="vertical sync end"/>
<arg name="vtotal" type="uint" summary="vertical total size"/>
<arg name="vscan" type="uint" summary="vertical scan"/>
<arg name="flags" type="uint" summary="flags, see DRM_MODE_FLAG_*"/>
</request>
</interface>
</protocol>
+7
View File
@@ -445,6 +445,13 @@ mod hyprland;
#[path = "vdisplay/linux/kwin.rs"] #[path = "vdisplay/linux/kwin.rs"]
mod kwin; mod kwin;
// In-process KDE output management (kde_output_management_v2) — the topology path that used to shell
// out to `kscreen-doctor`, driven over the compositor's own Wayland instead so it can't be wedged by
// a stuck libkscreen/kscreen-KDED backend. Consumed by `kwin` (best-effort, with kscreen fallback).
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin_output_mgmt.rs"]
mod kwin_output_mgmt;
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
#[path = "vdisplay/windows/manager.rs"] #[path = "vdisplay/windows/manager.rs"]
pub mod manager; pub mod manager;
@@ -61,6 +61,32 @@ static MANAGED_SESSION: std::sync::Mutex<Option<SessionState>> = std::sync::Mute
/// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects. /// (single-instance), so [`schedule_restore_tv_session`] can restart them when the client disconnects.
static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new()); static STOPPED_AUTOLOGIN: std::sync::Mutex<Vec<String>> = std::sync::Mutex::new(Vec::new());
/// The display-manager unit we stopped for the takeover on a mask-fragile DM flavor (Nobara's
/// `plasmalogin` — see [`dm_survives_masked_unit`]), so the restore brings the box back via
/// `reset-failed` + `restart` of the DM instead of a `--user start` of the gamescope unit (which
/// cannot work there: without a DM login session there is no seat, so gamescope never gets DRM
/// master — live-proven on the Nobara repro VM 2026-07-24).
static STOPPED_DM: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
/// mtime of the `steamos-session-select` sentinel at managed-session launch — the baseline the
/// in-stream "Switch to Desktop" detector compares against. Steam's session-select script writes
/// `~/.config/steamos-session-select` unconditionally in its USER pass, before any of its
/// display-manager checks — so it advances even under a DM-stop takeover, where the script's
/// config-rewrite tail is a silent no-op (every write branch is gated on the DM *running*;
/// diagnosed live on the Nobara repro VM 2026-07-24). An advanced mtime after a capture loss is
/// therefore the one durable trace of the user's switch request.
static SESSION_SELECT_BASELINE: std::sync::Mutex<Option<std::time::SystemTime>> =
std::sync::Mutex::new(None);
/// When [`honor_session_select_switch`] last ran. While recent, a managed (re)launch is refused —
/// the rebuild loop would otherwise race the booting desktop back into game mode (gamescope+Steam
/// come up faster than KWin, and a delivering managed pipeline ends the rebuild's re-detection).
static SWITCH_HONORED_AT: std::sync::Mutex<Option<Instant>> = std::sync::Mutex::new(None);
/// How long after honoring an in-stream desktop switch the managed path refuses to relaunch,
/// giving the DM's desktop session time to come up so re-detection follows it instead.
const SWITCH_HONOR_GRACE: Duration = Duration::from_secs(120);
/// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after /// A pending debounced TV-session restore: the instant [`do_restore_tv_session`] should fire after
/// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm /// the last client disconnect. A reconnect inside the window clears it (and reuses the still-warm
/// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is /// managed session), so we never stop+relaunch gamescope per connect — that per-connect teardown is
@@ -115,6 +141,10 @@ struct TakeoverState {
stopped_autologin: Vec<String>, stopped_autologin: Vec<String>,
/// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart). /// Whether we took over SteamOS's `gamescope-session.target` (restore = remove drop-in + restart).
steamos: bool, steamos: bool,
/// The display-manager unit we stopped on a mask-fragile DM flavor (restore = `reset-failed` +
/// `restart` of the DM). `default` so takeover files from older hosts still parse.
#[serde(default)]
stopped_dm: Option<String>,
} }
/// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared /// Path of the persisted [`TakeoverState`], under `$XDG_RUNTIME_DIR` (per-user, 0700, tmpfs — cleared
@@ -133,8 +163,9 @@ fn persist_takeover() {
.unwrap_or_else(|e| e.into_inner()) .unwrap_or_else(|e| e.into_inner())
.clone(), .clone(),
steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()), steamos: *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()),
stopped_dm: STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone(),
}; };
if state.stopped_autologin.is_empty() && !state.steamos { if state.stopped_autologin.is_empty() && !state.steamos && state.stopped_dm.is_none() {
clear_takeover(); clear_takeover();
return; return;
} }
@@ -162,17 +193,22 @@ pub fn restore_takeover_on_startup() {
clear_takeover(); clear_takeover();
return; return;
}; };
if state.stopped_autologin.is_empty() && !state.steamos { if state.stopped_autologin.is_empty() && !state.steamos && state.stopped_dm.is_none() {
clear_takeover(); clear_takeover();
return; return;
} }
tracing::warn!( tracing::warn!(
units = ?state.stopped_autologin, units = ?state.stopped_autologin,
steamos = state.steamos, steamos = state.steamos,
stopped_dm = ?state.stopped_dm,
"gamescope: found a stranded takeover from a previous host instance — scheduling TV restore" "gamescope: found a stranded takeover from a previous host instance — scheduling TV restore"
); );
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin; *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_autologin;
*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos; *STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()) = state.steamos;
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = state.stopped_dm;
// Re-baseline the session-select sentinel: after a crash-restore the launch-time baseline is
// gone, and a long-existing sentinel file must not read as a fresh in-stream switch request.
record_session_select_baseline();
// A generous grace so a client reconnecting right after the restart cancels it (create_managed_session // A generous grace so a client reconnecting right after the restart cancels it (create_managed_session
// clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode. // clears PENDING_RESTORE) and keeps the streamed session rather than bouncing to gaming mode.
*PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) = *PENDING_RESTORE.lock().unwrap_or_else(|e| e.into_inner()) =
@@ -263,7 +299,10 @@ impl VirtualDisplay for GamescopeDisplay {
// A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via // A3 takeover machinery (recorded in STOPPED_AUTOLOGIN + persisted; restarted on session end via
// schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this. // schedule_restore_tv_session). Non-Steam launches don't conflict, so they skip this.
if self.cmd.as_deref().is_some_and(is_steam_launch) { if self.cmd.as_deref().is_some_and(is_steam_launch) {
stop_autologin_sessions(); // A dedicated launch NEEDS Steam's single instance — no attach degrade exists here, so
// a mask-fragile-DM box without takeover privilege fails with the actionable error.
stop_autologin_sessions()
.context("dedicated Steam launch needs the box's gaming session freed")?;
// B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just // B1b: a Steam running in a plain DESKTOP session (GNOME/KDE) holds the instance just
// the same, and the autologin stop above can't see it — free it too, or fail loudly. // the same, and the autologin stop above can't see it — free it too, or fail loudly.
free_desktop_steam()?; free_desktop_steam()?;
@@ -326,6 +365,40 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
if steamos_session_present() { if steamos_session_present() {
return create_managed_session_steamos(mode); return create_managed_session_steamos(mode);
} }
// In-stream "Switch to Desktop" under a DM-stop takeover: the user's session-select inside
// the streamed game mode advanced the sentinel, but its config rewrite was a silent no-op
// (every write branch needs the DM running, and the takeover stopped it) — so without this,
// the capture loss it caused would just relaunch game mode ("thrown back in", field-tested
// 2026-07-24). Honor the request instead: restore the DM and replay the switch.
let dm_takeover = STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(dm) = dm_takeover {
if session_select_requested() {
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = None;
honor_session_select_switch(dm);
return Err(anyhow!(
"the user switched the box to the desktop session — display manager restored; \
re-detection follows the desktop compositor as it comes up"
));
}
}
// Post-honor grace: while the selected desktop boots, a managed relaunch would win the race
// (gamescope+Steam start faster than KWin) and a delivering pipeline ends the rebuild's
// re-detection — right back in game mode. A live box-owned game-mode unit supersedes the
// grace: the user already switched back, so managed may proceed.
let honor_pending = SWITCH_HONORED_AT
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_some_and(|t| t.elapsed() < SWITCH_HONOR_GRACE);
if honor_pending {
if running_autologin_gamescope_unit().is_some() {
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = None;
} else {
return Err(anyhow!(
"waiting for the desktop session the user selected — refusing to relaunch game \
mode (re-detection follows the desktop once it's up)"
));
}
}
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box // Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
// sessions — right after a capture loss the caller's session detection can be stale, and a // sessions — right after a capture loss the caller's session detection can be stale, and a
// destructive rebuild here would fight the session the user just switched to. // destructive rebuild here would fight the session the user just switched to.
@@ -353,7 +426,28 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and // Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by // renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
// stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce. // stopping it; [`schedule_restore_tv_session`] (on disconnect) brings it back after a debounce.
stop_autologin_sessions(); // On a mask-fragile-DM box without the privilege to stop the DM, the takeover would destabilize
// the seat — degrade to ATTACH instead: mirror the box's own live game-mode session (capture +
// inject, no lifecycle ownership), which needs no takeover at all.
if let Err(e) = stop_autologin_sessions() {
tracing::warn!(
error = %format!("{e:#}"),
"gamescope: managed takeover unavailable — degrading to ATTACH (mirroring the box's \
own game-mode session)"
);
let node_id = ensure_box_gamescope_mode(mode)?;
point_injector_at_eis();
return Ok(VirtualOutput {
node_id,
remote_fd: None,
preferred_mode: Some((mode.width, mode.height, mode.refresh_hz)),
keepalive: Box::new(()),
ownership: DisplayOwnership::External,
reused_gen: None,
pool_gen: None,
expect_exact_dims: false,
});
}
// B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and // B1b: a desktop-session Steam (outside any gamescope unit) also holds the single instance and
// would make the managed session's own Steam exit at birth. The managed session's Steam itself // would make the managed session's own Steam exit at birth. The managed session's Steam itself
// is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected. // is exempt (it lives in the SESSION_UNIT cgroup), so the same-mode reuse below is unaffected.
@@ -379,7 +473,21 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
} }
// (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is // (Re)launch at the new mode. `launch_session` stops the old unit by name first, so there is
// exactly one gamescope `Video/Source` node for discovery. // exactly one gamescope `Video/Source` node for discovery.
let node_id = launch_session(client, SESSION_UNIT, mode)?; let node_id = match launch_session(client, SESSION_UNIT, mode) {
Ok(id) => id,
Err(e) => {
// The takeover already happened (autologin units stopped, possibly the DM down) — arm
// the restore now, or a failed launch strands the box sessionless until a host
// restart. Policy-timed; a quick client retry cancels it and relaunches warm.
// MANAGED_SESSION must be released first: the scheduler reads it (orphan detection).
drop(guard);
schedule_restore_tv_session();
return Err(e);
}
};
// Baseline the session-select sentinel NOW: only a write from INSIDE this session (the user's
// "Switch to Desktop") should read as a switch request, not the one that led here.
record_session_select_baseline();
point_injector_at_eis(); point_injector_at_eis();
*guard = Some(SessionState { *guard = Some(SessionState {
width: mode.width, width: mode.width,
@@ -807,6 +915,38 @@ fn ensure_box_gamescope_mode(mode: Mode) -> Result<u32> {
return Ok(node); return Ok(node);
} }
} }
// Attach-only rebuild probe (parity with both managed paths — this gap was the attach-path
// stale-detection hazard): right after a capture loss the caller's session detection can be
// stale, and a set-environment + unit restart here would fight the session the user just
// switched to. Mirror whatever live node exists at its own mode; refuse otherwise.
if crate::rebuild_probe_active() {
if let Some(node) = find_gamescope_node() {
tracing::info!(
node,
"gamescope: attach-only rebuild probe — mirroring the live node at its own mode"
);
return Ok(node);
}
return Err(anyhow!(
"no live gamescope node — attach-only rebuild probe refuses to restart the box's \
session (re-detection follows the live session)"
));
}
// A box driving a PHYSICAL display is mirrored at its own mode, never re-moded: the re-mode
// restart is the headless-box model (no panel ⇒ the game-mode resolution is ours to set);
// on-glass it would flip the user's own screen to the client's resolution — and on a
// DM-session-driven box (Nobara) the unit restart bounces the login session with it.
if physical_display_connected() {
if let Some(node) = find_gamescope_node() {
tracing::info!(
node,
client_w = mode.width,
client_h = mode.height,
"gamescope: box drives a physical display — attaching at its own mode (no re-mode)"
);
return Ok(node);
}
}
let Some(unit) = running_autologin_gamescope_unit() else { let Some(unit) = running_autologin_gamescope_unit() else {
// No box-owned autologin session to reconfigure (a bare/foreign gamescope): attach to // No box-owned autologin session to reconfigure (a bare/foreign gamescope): attach to
// whatever node exists, accepting its resolution. // whatever node exists, accepting its resolution.
@@ -972,17 +1112,193 @@ fn unmask_unit(unit: &str) {
.status(); .status();
} }
/// The unit name of the display manager driving this box's graphical logins, from the
/// `display-manager.service` alias symlink (the Fedora/Arch/openSUSE convention every
/// gamescope-session distro follows). `None` when no DM is installed (a box that boots straight
/// into a user session — getty autologin / an enabled user unit).
fn display_manager_unit() -> Option<String> {
display_manager_unit_under(std::path::Path::new("/etc/systemd/system"))
}
/// [`display_manager_unit`] against an arbitrary root (the unit-testable core).
fn display_manager_unit_under(base: &std::path::Path) -> Option<String> {
let target = std::fs::read_link(base.join("display-manager.service")).ok()?;
target.file_name().map(|n| n.to_string_lossy().into_owned())
}
/// Does this display manager's autologin loop SURVIVE the gamescope unit being masked? Only SDDM is
/// proven to (Bazzite/SteamOS `Relogin=true` — the mask is what stops its relogin from restarting
/// the unit mid-stream, diagnosed live on .181 2026-07-07; a failing autologin leaves sddm itself
/// running). Nobara's `plasmalogin` (KDE's SDDM successor) is proven FATAL: against a masked unit
/// its session Exec fails instantly, `Relogin=true` retries, and `plasmalogin.service` trips
/// systemd's start limit within ~1 s — the DM dies and the box is a permanent black screen that
/// only a root `reset-failed` + `restart` recovers (live-proven on the Nobara repro VM
/// 2026-07-24). Unknown DMs are treated as fragile: the fragile path degrades gracefully, a wrong
/// "safe" kills the seat.
fn dm_survives_masked_unit(dm: &str) -> bool {
dm == "sddm.service"
}
/// Stop the display manager for a takeover on a mask-fragile DM flavor. Plain `systemctl stop` on
/// the SYSTEM bus — succeeds as root or under an operator polkit rule scoped to the DM unit (see
/// docs); fails cleanly otherwise ("interactive authentication required"), in which case the
/// caller degrades to attach.
fn try_stop_display_manager(dm: &str) -> bool {
Command::new("systemctl")
.args(["stop", dm])
.status()
.map(|s| s.success())
.unwrap_or(false)
}
/// The distro's session-switch helper (ChimeraOS/Nobara layout). Its USER pass records the
/// sentinel + self-pkexecs (authorized `allow_any` by the distro's own polkit action policy, so
/// it works from our sessionless context); its ROOT pass rewrites the DM autologin config — but
/// only while the DM is RUNNING, which is why the takeover must restart the DM before calling it.
const OS_SESSION_SELECT: &str = "/usr/libexec/os-session-select";
/// The sentinel Steam's `steamos-session-select` writes in its user pass
/// (`~/.config/steamos-session-select`) — see [`SESSION_SELECT_BASELINE`].
fn session_select_sentinel() -> Option<std::path::PathBuf> {
let home = std::env::var("HOME").ok()?;
Some(
std::path::Path::new(&home)
.join(".config")
.join("steamos-session-select"),
)
}
/// Current mtime of the session-select sentinel (`None` when it doesn't exist yet).
fn session_select_mtime() -> Option<std::time::SystemTime> {
let path = session_select_sentinel()?;
std::fs::metadata(path).ok()?.modified().ok()
}
/// Record the sentinel baseline at managed-session launch, so a LATER write (the user's in-stream
/// "Switch to Desktop") is distinguishable from the switch that led into this session.
fn record_session_select_baseline() {
*SESSION_SELECT_BASELINE
.lock()
.unwrap_or_else(|e| e.into_inner()) = session_select_mtime();
}
/// Did a session-select run inside the managed session since its launch (sentinel newer than the
/// recorded baseline, or newly created)? Inside a managed game session the only switch Steam
/// offers is TO the desktop, so an advanced sentinel reads as that request.
fn session_select_requested() -> bool {
let baseline = *SESSION_SELECT_BASELINE
.lock()
.unwrap_or_else(|e| e.into_inner());
match (baseline, session_select_mtime()) {
(Some(base), Some(now)) => now > base,
(None, Some(_)) => true, // created during the session
_ => false,
}
}
/// Honor the user's in-stream "Switch to Desktop" under a DM-stop takeover. The OS flow was a
/// silent no-op (the switch script's config rewrite requires a running DM, which the takeover
/// stopped), so replay it with the DM up — every verb live-validated on the Nobara repro VM:
/// 1. consume the takeover and start the DM (its autologin heads back into game mode briefly —
/// the config still names it);
/// 2. run the distro's own `os-session-select desktop` as the user (its internal pkexec is
/// `allow_any`-authorized), which rewrites the DM autologin config to the desktop session;
/// 3. stop the autologin gamescope unit — the login session exits, and `Relogin=true` relogs
/// into the now-selected desktop.
///
/// The caller then refuses managed relaunches for [`SWITCH_HONOR_GRACE`] so the capture-loss
/// re-detection follows the desktop compositor once it's up instead of racing it.
fn honor_session_select_switch(dm: String) {
tracing::info!(
%dm,
"gamescope: in-stream session-select detected — restoring the display manager and \
switching the box to the desktop session"
);
// Consume the takeover state up front: from here on the box is the DM's again.
std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
clear_takeover();
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
stop_session(SESSION_UNIT); // dead already (the switch shut its Steam down) — clear the unit
let _ = Command::new("systemctl")
.args(["reset-failed", &dm])
.status();
let _ = Command::new("systemctl").args(["start", &dm]).status();
let deadline = Instant::now() + Duration::from_secs(10);
while Instant::now() < deadline {
let active = Command::new("systemctl")
.args(["is-active", &dm])
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).trim() == "active")
.unwrap_or(false);
if active {
break;
}
std::thread::sleep(Duration::from_millis(500));
}
// Rewrite the autologin session via the distro's own switch helper (needs the DM running).
// Absent/failing helper degrades to a plain DM restore — the box lands back in game mode on
// glass and the stream follows that instead (no black screen either way).
if std::path::Path::new(OS_SESSION_SELECT).exists() {
match Command::new(OS_SESSION_SELECT).arg("desktop").status() {
Ok(s) if s.success() => {
// The relogin only fires when the CURRENT (game-mode) login session exits: wait
// for its autologin unit to come up, then stop it. Never mask here — the mask is
// what start-limit-kills this DM flavor.
let deadline = Instant::now() + Duration::from_secs(15);
loop {
if let Some(unit) = running_autologin_gamescope_unit() {
systemctl_user(&["stop", &unit]);
tracing::info!(
%unit,
"gamescope: desktop selected — stopped the game-mode session so the \
DM relogs into the desktop"
);
break;
}
if Instant::now() >= deadline {
tracing::warn!(
"gamescope: game-mode session never appeared after the DM restart — \
the desktop switch may need a manual session exit"
);
break;
}
std::thread::sleep(Duration::from_millis(500));
}
}
other => tracing::warn!(
status = ?other,
"gamescope: os-session-select failed — leaving the box in its configured session"
),
}
} else {
tracing::warn!(
"gamescope: no {OS_SESSION_SELECT} on this box — restored the DM into its configured \
session instead of switching to the desktop"
);
}
record_session_select_baseline();
*SWITCH_HONORED_AT.lock().unwrap_or_else(|e| e.into_inner()) = Some(Instant::now());
}
/// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its /// Stop every autologin gaming-mode session (`gamescope-session-plus@*.service`) so its
/// single-instance Steam is free for our own host-managed session. Records the units so /// single-instance Steam is free for our own host-managed session. Records the units so
/// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient /// [`schedule_restore_tv_session`] can restart them on disconnect. Our own session is the transient
/// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing /// `punktfunk-gamescope` unit (not a `@`-instance), so it's never matched here. No-op when nothing
/// is autologged in (e.g. a box that boots headless). Each unit is **masked first** ([`mask_unit`] — /// is autologged in (e.g. a box that boots headless).
/// SDDM's `Relogin=true` would otherwise restart it instantly), then torn down with **SIGKILL** ///
/// ([`kill_unit`]) to avoid the F44 GPU-context leak that the autologin's SIGTERM stop triggers. /// The teardown is DM-flavor-aware ([`dm_survives_masked_unit`]):
/// Matches every loaded instance, not just `running` ones — under the SDDM relogin churn the unit /// * **SDDM / no DM**: each unit is **masked first** ([`mask_unit`] — SDDM's `Relogin=true` would
/// flaps through `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the /// otherwise restart it instantly), then torn down with **SIGKILL** ([`kill_unit`]) to avoid the
/// fight the moment the supervisor restarts it. /// F44 GPU-context leak that the autologin's SIGTERM stop triggers. Matches every loaded
fn stop_autologin_sessions() { /// instance, not just `running` ones — under the SDDM relogin churn the unit flaps through
/// `activating`/`failed` between cycles, and an unmasked flapping unit re-enters the fight the
/// moment the supervisor restarts it.
/// * **Mask-fragile DM** (Nobara's `plasmalogin`, unknown DMs): masking start-limit-kills the DM
/// itself (permanent black screen), so instead **stop the DM** — no supervisor left to relogin —
/// then SIGKILL the units unmasked. Needs privilege (root / an operator polkit rule); without it
/// nothing is touched and the error tells the caller to degrade to ATTACH (mirror the box's own
/// session) rather than destabilize the seat.
fn stop_autologin_sessions() -> Result<()> {
let Ok(out) = Command::new("systemctl") let Ok(out) = Command::new("systemctl")
.args([ .args([
"--user", "--user",
@@ -995,26 +1311,65 @@ fn stop_autologin_sessions() {
]) ])
.output() .output()
else { else {
return; return Ok(());
}; };
let mut stopped = Vec::new(); // `(unit, ACTIVE state)` — the `--plain` columns are UNIT LOAD ACTIVE SUB DESCRIPTION.
for line in String::from_utf8_lossy(&out.stdout).lines() { let listed: Vec<(String, String)> = String::from_utf8_lossy(&out.stdout)
if let Some(unit) = line.split_whitespace().next() { .lines()
if unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service") { .filter_map(|l| {
mask_unit(unit); // block the SDDM relogin loop from restarting it mid-stream let mut cols = l.split_whitespace();
kill_unit(unit); // SIGKILL teardown — avoid the F44 GPU-context leak let unit = cols.next()?;
tracing::info!( let active = cols.nth(1).unwrap_or("");
unit, (unit.starts_with("gamescope-session-plus@") && unit.ends_with(".service"))
"freed Steam: masked + SIGKILL-stopped the autologin gaming session for this stream" .then(|| (unit.to_string(), active.to_string()))
})
.collect();
if listed.is_empty() {
return Ok(()); // nothing autologged in — Steam is already free
}
let dm = display_manager_unit();
let mask_safe = dm.as_deref().is_none_or(dm_survives_masked_unit);
if !mask_safe {
// Only a LIVE instance holds Steam / justifies touching the DM. A loaded-but-inactive
// leftover (the box switched back to the desktop earlier) must not stop the DM — that
// would kill the user's live desktop to free nothing.
if !listed
.iter()
.any(|(_, active)| matches!(active.as_str(), "active" | "activating"))
{
return Ok(());
}
let dm = dm.expect("!is_none_or ⇒ Some");
if !try_stop_display_manager(&dm) {
bail!(
"the box's gaming session is driven by {dm}, which does not survive a masked \
session unit, and stopping it needs privilege — install the punktfunk \
display-manager polkit rule (see docs) to enable the managed takeover"
); );
stopped.push(unit.to_string());
} }
tracing::info!(
%dm,
"freed Steam: stopped the display manager for this stream (mask-fragile DM flavor)"
);
*STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()) = Some(dm);
} }
let units: Vec<String> = listed.into_iter().map(|(u, _)| u).collect();
let mut stopped = Vec::new();
for unit in units {
if mask_safe {
mask_unit(&unit); // block the SDDM relogin loop from restarting it mid-stream
}
kill_unit(&unit); // SIGKILL teardown — avoid the F44 GPU-context leak
tracing::info!(
%unit,
masked = mask_safe,
"freed Steam: stopped the autologin gaming session for this stream"
);
stopped.push(unit);
} }
if !stopped.is_empty() {
*STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped; *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()) = stopped;
persist_takeover(); // A3: survive a host crash mid-stream persist_takeover(); // A3: survive a host crash mid-stream
} Ok(())
} }
/// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears /// How long a desktop Steam gets to honor `steam -shutdown` before the spawn fails. Steam tears
@@ -1159,7 +1514,16 @@ pub fn schedule_restore_tv_session() {
.lock() .lock()
.unwrap_or_else(|e| e.into_inner()) .unwrap_or_else(|e| e.into_inner())
.is_empty() .is_empty()
&& !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner()); && !*STEAMOS_TOOK_OVER.lock().unwrap_or_else(|e| e.into_inner())
&& STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()).is_none()
// A managed session that took nothing over (started beside a live desktop — e.g. a client
// gamescope pin on a KDE box) still owns the transient SESSION_UNIT: without this arm it
// was ORPHANED forever after disconnect ("closing the app does not end the session",
// field report 2026-07-24) — the restore stops it even with no autologin to bring back.
&& MANAGED_SESSION
.lock()
.unwrap_or_else(|e| e.into_inner())
.is_none();
if nothing_to_restore { if nothing_to_restore {
return; // nothing was taken over → nothing to restore (also the non-managed path) return; // nothing was taken over → nothing to restore (also the non-managed path)
} }
@@ -1254,8 +1618,24 @@ fn do_restore_tv_session() {
} }
} }
let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner())); let units = std::mem::take(&mut *STOPPED_AUTOLOGIN.lock().unwrap_or_else(|e| e.into_inner()));
if units.is_empty() { let dm = std::mem::take(&mut *STOPPED_DM.lock().unwrap_or_else(|e| e.into_inner()));
return; // nothing was stolen → nothing to restore (also the non-Bazzite path) if units.is_empty() && dm.is_none() {
// Nothing was stolen — but a managed session that started BESIDE a live desktop (client
// gamescope pin on a KDE box) still owns the transient unit; stop it so it doesn't run
// orphaned forever after the disconnect. No-op when the unit isn't running.
if MANAGED_SESSION
.lock()
.unwrap_or_else(|e| e.into_inner())
.take()
.is_some()
{
stop_session(SESSION_UNIT);
tracing::info!(
"gamescope: stopped the idle managed session (nothing was taken over — no box \
session to restore)"
);
}
return;
} }
clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker clear_takeover(); // A3: takeover consumed — drop the persisted crash-restore marker
stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin stop_session(SESSION_UNIT); // our gamescope/Steam session, so Steam is free for the autologin
@@ -1266,18 +1646,54 @@ fn do_restore_tv_session() {
} }
*MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None; *MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner()) = None;
// Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the // Only bring the gaming autologin BACK if the box is still meant to be in gaming mode. If the
// user switched to a desktop session (KDE/GNOME/wlroots) in the meantime, don't yank them back // user switched to a desktop session (KDE/GNOME/wlroots/Hyprland) in the meantime, don't yank
// to gaming — leave the desktop alone. (We still stopped our idle managed session above.) // them back to gaming — leave the desktop alone. (We still stopped our idle managed session
// above.)
use super::ActiveKind; use super::ActiveKind;
if matches!( if matches!(
super::detect_active_session().kind, super::detect_active_session().kind,
ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots ActiveKind::DesktopKde
| ActiveKind::DesktopGnome
| ActiveKind::DesktopWlroots
| ActiveKind::DesktopHyprland
) { ) {
tracing::info!( tracing::info!(
"gamescope: a desktop session is active — not restoring the TV gaming session" "gamescope: a desktop session is active — not restoring the TV gaming session"
); );
return; return;
} }
// Mask-fragile-DM takeover: the gamescope unit CANNOT be `--user start`ed back — without a DM
// login session there is no seat, so gamescope never gets DRM master (unit goes `failed`,
// screen stays black — live-proven on the Nobara repro VM). Restore the DM instead
// (`reset-failed` clears the relogin start-limit accounting, then `restart`); its autologin
// session Exec starts the gamescope unit itself.
if let Some(dm) = dm {
let _ = Command::new("systemctl")
.args(["reset-failed", &dm])
.status();
let restart = Command::new("systemctl")
.args(["restart", &dm])
.status()
.map(|s| s.success())
.unwrap_or(false);
if restart {
tracing::info!(%dm, "restored the display manager (its autologin brings gaming mode back)");
} else if crate::try_recover_session() {
tracing::warn!(
%dm,
"display-manager restart lost its privilege — fired PUNKTFUNK_RECOVER_SESSION_CMD \
to bring the session back"
);
} else {
tracing::error!(
%dm,
"could not restart the display manager and no PUNKTFUNK_RECOVER_SESSION_CMD is \
configured — the box has no graphical session until someone runs \
`systemctl reset-failed {dm} && systemctl restart {dm}` as root"
);
}
return;
}
for unit in units { for unit in units {
let _ = Command::new("systemctl") let _ = Command::new("systemctl")
.args(["--user", "start", &unit]) .args(["--user", "start", &unit])
@@ -1641,10 +2057,35 @@ impl Drop for GamescopeProc {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
cgroup_is_punktfunk_owned, connected_connector_under, is_steam_launch, cgroup_is_punktfunk_owned, connected_connector_under, display_manager_unit_under,
shape_dedicated_command, dm_survives_masked_unit, is_steam_launch, shape_dedicated_command,
}; };
#[test]
fn display_manager_flavor_detection() {
let base = std::env::temp_dir().join(format!("pf-dm-scan-{}", std::process::id()));
std::fs::create_dir_all(&base).unwrap();
// No alias symlink (no DM installed — getty autologin boxes) → None.
assert_eq!(display_manager_unit_under(&base), None);
// The Fedora-style alias symlink resolves to its target's basename (read_link, not
// canonicalize — the target needn't exist on the build box).
std::os::unix::fs::symlink(
"/usr/lib/systemd/system/plasmalogin.service",
base.join("display-manager.service"),
)
.unwrap();
assert_eq!(
display_manager_unit_under(&base).as_deref(),
Some("plasmalogin.service")
);
// Only SDDM is proven to survive a masked session unit; plasmalogin start-limit-kills
// itself (live-proven), and unknown DMs default to fragile.
assert!(dm_survives_masked_unit("sddm.service"));
assert!(!dm_survives_masked_unit("plasmalogin.service"));
assert!(!dm_survives_masked_unit("gdm.service"));
std::fs::remove_dir_all(&base).unwrap();
}
#[test] #[test]
fn connector_status_scan() { fn connector_status_scan() {
let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id())); let base = std::env::temp_dir().join(format!("pf-drm-scan-{}", std::process::id()));
+111 -38
View File
@@ -89,6 +89,11 @@ pub struct KwinDisplay {
/// [`apply_position`](VirtualDisplay::apply_position) addresses OUR output even while a /// [`apply_position`](VirtualDisplay::apply_position) addresses OUR output even while a
/// superseded same-name sibling is still alive. /// superseded same-name sibling is still alive.
last_name: Option<String>, last_name: Option<String>,
/// The RESOLVED `kde_output_device_v2` UUID of the last `create`'s output, when the in-process
/// output-management path handled the topology. A stable per-output id (unlike the shared name),
/// so [`apply_position`](VirtualDisplay::apply_position) and restore address exactly OUR output
/// across a supersede — preferred over `last_name`, which is only the kscreen-doctor fallback.
our_uuid: Option<String>,
/// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive` /// The topology-restore action the last `create` prepared (re-enable the outputs an `exclusive`
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the /// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's. /// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
@@ -114,6 +119,48 @@ impl KwinDisplay {
pub fn new() -> Result<Self> { pub fn new() -> Result<Self> {
Ok(KwinDisplay::default()) Ok(KwinDisplay::default())
} }
/// Apply the effective display topology for the just-created output `our_prefix` (current size
/// `dims`), preferring the in-process `kde_output_management_v2` path and falling back to
/// `kscreen-doctor` if the compositor doesn't answer in budget or the management global is
/// absent. Records the output's UUID (in-process) or kscreen address (fallback) for
/// [`apply_position`](VirtualDisplay::apply_position), and returns the disabled outputs (each
/// `(name, "WxH@Hz")`) for the group teardown restore. `Extend`/`Auto` disable nothing.
fn apply_topology(
&mut self,
name: &str,
our_prefix: &str,
dims: (u32, u32),
) -> Vec<(String, String)> {
use crate::kwin_output_mgmt::TopologyKind;
use crate::policy::Topology;
let topology = crate::effective_topology();
let kind = match topology {
Topology::Exclusive => TopologyKind::Exclusive,
Topology::Primary => TopologyKind::Primary,
Topology::Extend | Topology::Auto => return Vec::new(),
};
// In-process over Wayland — immune to whatever wedges the standalone kscreen-doctor.
let outcome = crate::kwin_output_mgmt::apply_topology(our_prefix, dims.0, dims.1, kind);
if outcome.handled {
self.our_uuid = outcome.our_uuid;
return outcome.disabled;
}
// Fallback: kscreen-doctor — resolve our address the old way, then shell out the topology.
tracing::info!(
"KWin topology: kde_output_management unavailable — kscreen-doctor fallback"
);
let addr = resolve_kscreen_addr(name, dims.0, dims.1);
self.last_name = Some(addr.clone());
match topology {
Topology::Exclusive => apply_virtual_primary(&addr),
Topology::Primary => {
apply_virtual_primary_only(&addr);
Vec::new()
}
Topology::Extend | Topology::Auto => Vec::new(),
}
}
} }
impl VirtualDisplay for KwinDisplay { impl VirtualDisplay for KwinDisplay {
@@ -142,8 +189,15 @@ impl VirtualDisplay for KwinDisplay {
} }
fn apply_position(&mut self, x: i32, y: i32) { fn apply_position(&mut self, x: i32, y: i32) {
// `last_name` holds the RESOLVED kscreen address (numeric output id, or the // Prefer the in-process path: address OUR output by its stable UUID (supersede-robust) over
// `Virtual-<name>` fallback) — never re-derive from the name: during a supersede two // kde_output_management_v2 — immune to a wedged kscreen-doctor backend (see kwin_output_mgmt).
if let Some(uuid) = self.our_uuid.clone() {
if crate::kwin_output_mgmt::set_position(&uuid, x, y) {
return;
}
}
// Fallback: kscreen-doctor. `last_name` holds the RESOLVED kscreen address (numeric output id,
// or the `Virtual-<name>` fallback) — never re-derive from the name: during a supersede two
// outputs share it and the command would hit the old one (see `create`). // outputs share it and the command would hit the old one (see `create`).
let Some(output) = self.last_name.clone() else { let Some(output) = self.last_name.clone() else {
return; return;
@@ -210,12 +264,12 @@ impl VirtualDisplay for KwinDisplay {
// on virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded // on virtual outputs since KWin 6.6) then changes the SIZE, and the first buffers recorded
// after the consumer connects trigger KWin's resize → a renegotiation to that mode. The // after the consumer connects trigger KWin's resize → a renegotiation to that mode. The
// capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never // capturer holds frames until that lands (`expect_exact_dims`), so the pipeline never
// builds against the birth mode. First cut shells out to kscreen-doctor; the in-process // builds against the birth mode. The install/select runs in-process over
// kde_output_management_v2 client is a follow-up. `set_custom_refresh` reads back what // kde_output_management_v2 (`kwin_output_mgmt::set_custom_mode`), with kscreen-doctor
// KWin *actually* gave — both the rate (so the encoder paces to the real source) and the // (`set_custom_refresh`) as the fallback; either reads back what KWin *actually* gave — both
// size, which KWin's CVT generator may have aligned down (see `CVT_H_GRANULARITY`). At // the rate (so the encoder paces to the real source) and the size, which KWin's CVT
// ≤60 Hz there's nothing to install — the output is born at the real size and 60 Hz is the // generator may have aligned down (see `CVT_H_GRANULARITY`). At ≤60 Hz there's nothing to
// offer anyway. // install — the output is born at the real size and 60 Hz is the offer anyway.
let want_high = mode.refresh_hz > 60; let want_high = mode.refresh_hz > 60;
let birth_h = if want_high { height + 16 } else { height }; let birth_h = if want_high { height + 16 } else { height };
let (mut node_id, mut stop) = spawn_vout(width, birth_h)?; let (mut node_id, mut stop) = spawn_vout(width, birth_h)?;
@@ -227,17 +281,10 @@ impl VirtualDisplay for KwinDisplay {
embedded_pointer = !self.hw_cursor, embedded_pointer = !self.hw_cursor,
"KWin virtual output ready" "KWin virtual output ready"
); );
// ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede (mode switch) creates the // Topology + positioning address OUR output by its kde_output_management UUID (resolved
// replacement output — SAME per-slot name, deliberately, for KWin's per-name config // in-process in `apply_topology`, supersede-robust) — no early kscreen-doctor resolve, so
// persistence — while the superseded sibling is still alive (create-before-drop). Every // the path never shells out. `Virtual-<name>` is the name KWin exposes our output as.
// name-addressed kscreen command then hits the FIRST match = the OLD output: on-glass this let our_prefix = format!("Virtual-{name}");
// resized the LIVE session's display out from under it (wrong-res/black), read back the
// OLD output as "custom mode applied", made the OLD output primary, and positioned it —
// while the new output never left its birth mode and the capturer's dims gate starved.
// Resolve OUR output's kscreen id once (match: managed-prefix name AND current mode ==
// the just-created birth size; newest id wins), and use it for every kscreen operation.
let mut addr = resolve_kscreen_addr(&name, width, birth_h);
self.last_name = Some(addr.clone()); // for apply_position (registry-driven §6.2 layout)
let mut expect_exact_dims = false; let mut expect_exact_dims = false;
// The size the output actually ENDS UP at — the request, unless KWin's CVT generator had to // The size the output actually ENDS UP at — the request, unless KWin's CVT generator had to
// shrink the width to the cell grain (see `CVT_H_GRANULARITY`). Reported as the output's // shrink the width to the cell grain (see `CVT_H_GRANULARITY`). Reported as the output's
@@ -245,7 +292,27 @@ impl VirtualDisplay for KwinDisplay {
// encoder opens against, so a CVT-aligned mode flows end-to-end instead of starving. // encoder opens against, so a CVT-aligned mode flows end-to-end instead of starving.
let mut final_dims = (width, height); let mut final_dims = (width, height);
let achieved_hz = if want_high { let achieved_hz = if want_high {
let active = set_custom_refresh(width, height, mode.refresh_hz, &addr); // >60 Hz needs the real high-refresh custom mode installed + selected (sacrificial-birth,
// see above). In-process over kde_output_management_v2 first (no kscreen-doctor); fall
// back to the kscreen-doctor shell-out on pre-6.6 KWin (no `set_custom_modes`) or if the
// compositor doesn't answer in budget.
let active = crate::kwin_output_mgmt::set_custom_mode(
&our_prefix,
width,
birth_h,
width,
height,
mode.refresh_hz,
)
.or_else(|| {
// ⚠️ ADDRESS BY NUMERIC KSCREEN ID, NEVER BY NAME: a supersede reuses the per-slot
// name while the superseded sibling is still alive, so a name-addressed kscreen
// command hits the OLD output. Resolve our kscreen id for the shell-out install +
// keep it as apply_position's kscreen fallback.
let addr = resolve_kscreen_addr(&name, width, birth_h);
self.last_name = Some(addr.clone());
set_custom_refresh(width, height, mode.refresh_hz, &addr)
});
// Accept only an active mode that IS our custom one: the exact requested height, and a // Accept only an active mode that IS our custom one: the exact requested height, and a
// width at or just below the request (a CVT alignment). That also proves the output // width at or just below the request (a CVT alignment). That also proves the output
// left the sacrificial birth size, so the recording stream will renegotiate to it. // left the sacrificial birth size, so the recording stream will renegotiate to it.
@@ -275,8 +342,6 @@ impl VirtualDisplay for KwinDisplay {
let (nid, st) = spawn_vout(width, height)?; let (nid, st) = spawn_vout(width, height)?;
node_id = nid; node_id = nid;
stop = st; stop = st;
addr = resolve_kscreen_addr(&name, width, height);
self.last_name = Some(addr.clone());
tracing::info!( tracing::info!(
node_id, node_id,
width, width,
@@ -293,16 +358,16 @@ impl VirtualDisplay for KwinDisplay {
// `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled; // `Primary` makes it the primary output but keeps the bootstrap/physical outputs enabled;
// `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so // `Exclusive` makes it the SOLE desktop (others disabled, restored on teardown) — so
// plasmashell + windows land on the streamed surface, not the headless `kwin --virtual` // plasmashell + windows land on the streamed surface, not the headless `kwin --virtual`
// bootstrap output. Read from the policy (replacing the PUNKTFUNK_KWIN_VIRTUAL_PRIMARY boolean). // bootstrap output. Applied over kde_output_management_v2 in-process (immune to a wedged
use crate::policy::Topology; // kscreen-doctor backend; see `apply_topology`), with a kscreen-doctor fallback. `disabled`
let disabled = match crate::effective_topology() { // is the physical/bootstrap outputs, each `(name, "WxH@Hz")`, to restore on teardown.
Topology::Exclusive => apply_virtual_primary(&addr), let disabled = self.apply_topology(&name, &our_prefix, final_dims);
Topology::Primary => { // A plain managed name is enough for apply_position's kscreen-doctor fallback when the
apply_virtual_primary_only(&addr); // in-process UUID path isn't set (single-output sessions are unambiguous; a supersede uses
Vec::new() // nothing disabled → nothing to restore // the UUID path instead). `want_high` already set `last_name` to the resolved kscreen id.
if self.last_name.is_none() {
self.last_name = Some(our_prefix);
} }
Topology::Extend | Topology::Auto => Vec::new(),
};
// Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session // Per-group restore (§6.1): DON'T bind the re-enable to this session's keepalive (a per-session
// `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive // `StopGuard` restore would re-enable the physical the moment the FIRST of several exclusive
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts // sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
@@ -310,7 +375,12 @@ impl VirtualDisplay for KwinDisplay {
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore. // that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
self.pending_restore = (!disabled.is_empty()).then(|| { self.pending_restore = (!disabled.is_empty()).then(|| {
let disabled = disabled.clone(); let disabled = disabled.clone();
Box::new(move || reenable_outputs(&disabled)) as Box<dyn FnOnce() + Send> // In-process first; fall back to kscreen-doctor if the compositor doesn't answer in budget.
Box::new(move || {
if !crate::kwin_output_mgmt::reenable_outputs(&disabled) {
reenable_outputs_kscreen(&disabled);
}
}) as Box<dyn FnOnce() + Send>
}); });
// Layout position (§6.2) is applied by the registry via `apply_position` right after create // Layout position (§6.2) is applied by the registry via `apply_position` right after create
// (it owns the display group, so it computes auto-row / manual placement over the whole group). // (it owns the display group, so it computes auto-row / manual placement over the whole group).
@@ -324,10 +394,12 @@ impl VirtualDisplay for KwinDisplay {
} }
} }
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto /// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical) via `kscreen-doctor`
/// them. Called by the registry when the display group's last member is torn down (design §6.1), BEFORE /// the fallback for the in-process [`crate::kwin_output_mgmt::reenable_outputs`], run by the restore
/// that member's output is reclaimed — so KWin is never momentarily left with zero enabled outputs. /// closure only when the in-process path reports the compositor didn't answer. Called by the registry
fn reenable_outputs(outputs: &[(String, String)]) { /// when the display group's last member is torn down (design §6.1), BEFORE that member's output is
/// reclaimed — so KWin is never momentarily left with zero enabled outputs.
fn reenable_outputs_kscreen(outputs: &[(String, String)]) {
if outputs.is_empty() { if outputs.is_empty() {
return; return;
} }
@@ -823,8 +895,9 @@ fn apply_virtual_primary_only(ours: &str) {
/// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which /// Dropping this releases the KWin virtual output: it flips the keepalive thread's `stop`, which
/// drops the Wayland connection and makes KWin reclaim the output. The topology **restore** is no /// drops the Wayland connection and makes KWin reclaim the output. The topology **restore** is no
/// longer bound here — it moved to the registry's display group (§6.1, [`reenable_outputs`]), which /// longer bound here — it moved to the registry's display group (§6.1, restored in-process via
/// runs it once when the group's last member drops, BEFORE this keepalive is dropped. /// [`crate::kwin_output_mgmt::reenable_outputs`], `kscreen-doctor` fallback), which runs it once when
/// the group's last member drops, BEFORE this keepalive is dropped.
struct StopGuard { struct StopGuard {
stop: Arc<AtomicBool>, stop: Arc<AtomicBool>,
} }
@@ -0,0 +1,820 @@
//! In-process KDE output management (`kde_output_management_v2` + `kde_output_device_v2`).
//!
//! Topology — make the streamed output primary, disable the physical/bootstrap outputs, capture
//! their modes for restore, re-enable them on teardown, position the output — used to shell out to
//! `kscreen-doctor` (see [`super::kwin`]). But `kscreen-doctor` drives a *separate* stack:
//! libkscreen picks a backend and, depending on the setup, waits on the kscreen KDED module over
//! D-Bus. On a machine where THAT layer is wedged it blocks in its own connect and never returns —
//! a field report (Nobara, KWin 6.6.4) showed every topology `kscreen-doctor` timing out at its 5 s
//! budget, so the streamed output never became the desktop (`also_disabled=[]`) and bring-up took
//! ~26 s.
//!
//! The compositor's OWN Wayland is provably responsive on that same session — the host just created
//! a virtual output over it via `zkde_screencast` — so we drive `kde_output_management_v2` directly
//! over Wayland here, sidestepping whatever wedges the standalone tool. Every wait is time-bounded,
//! so a genuinely wedged compositor degrades to `handled = false` and the caller falls back to the
//! `kscreen-doctor` path rather than hanging.
//!
//! KWin advertises one `kde_output_device_v2` global per output (the classic model; verified live:
//! `kde_output_management_v2` v19, `kde_output_device_v2` v20 on KWin 6.6.4). We bind them all, read
//! each output's name / enabled / priority / current-mode size, then build a
//! `kde_output_configuration_v2` and `apply()` it, waiting for `applied` / `failed`.
#![allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
#![deny(clippy::undocumented_unsafe_blocks)]
use std::collections::HashMap;
use std::os::fd::{AsFd, AsRawFd};
use std::time::{Duration, Instant};
use wayland_client::backend::ObjectId;
use wayland_client::protocol::wl_callback::{self, WlCallback};
use wayland_client::protocol::wl_registry::{self, WlRegistry};
use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle};
// Generate the client bindings for the two vendored KDE protocols inline (no build.rs). The
// management protocol references the device interfaces, so they can't share one `__interfaces`
// module (each `generate_interfaces!` emits its own helper items, which collide). Instead — the
// interdependent-protocol pattern from the `wayland-protocols` crate — `device` is a self-contained
// module and `management` pulls in `device`'s interface statics + generated proxy types before its
// own codegen, so its cross-protocol object args (`kde_output_device_v2`, `…_mode_v2`) resolve.
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
pub mod device {
use wayland_client;
use wayland_client::protocol::*;
pub mod __interfaces {
use wayland_client::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("protocols/kde-output-device-v2.xml");
}
use self::__interfaces::*;
wayland_scanner::generate_client_code!("protocols/kde-output-device-v2.xml");
}
#[allow(clippy::all, dead_code, non_camel_case_types, non_snake_case, unused)]
pub mod management {
use wayland_client;
use wayland_client::protocol::*;
pub mod __interfaces {
use super::super::device::__interfaces::*;
use wayland_client::protocol::__interfaces::*;
wayland_scanner::generate_interfaces!("protocols/kde-output-management-v2.xml");
}
use self::__interfaces::*;
// The device protocol's generated modules/types, so the foreign object args resolve.
use super::device::*;
wayland_scanner::generate_client_code!("protocols/kde-output-management-v2.xml");
}
use device::kde_output_device_mode_v2::{Event as ModeEvent, KdeOutputDeviceModeV2 as DeviceMode};
use device::kde_output_device_v2::{Event as DeviceEvent, KdeOutputDeviceV2 as OutputDevice};
use management::kde_mode_list_v2::KdeModeListV2 as ModeList;
use management::kde_output_configuration_v2::{
Event as ConfigEvent, KdeOutputConfigurationV2 as OutputConfig,
};
use management::kde_output_management_v2::KdeOutputManagementV2 as OutputManagement;
/// Highest interface versions we drive; we bind `min(advertised, MAX)`. Every request we issue is
/// `since ≤ 2` (`create_configuration`/`enable`/`mode`/`position`/`apply` are v1, `set_primary_output`
/// is v2) and every event we read is `since ≤ 18` (`priority`), so binding high and calling low is
/// always in range on any KWin that advertises the globals.
const MGMT_MAX: u32 = 22;
const DEVICE_MAX: u32 = 24;
/// The opcode of `kde_output_device_v2.mode` (0-based event index) — the event that creates a child
/// `kde_output_device_mode_v2`. Kept in sync with the vendored `kde-output-device-v2.xml`.
const DEVICE_MODE_EVENT_OPCODE: u16 = 2;
/// Overall budget for one enumerate-then-apply operation. Generous next to a healthy roundtrip (a
/// few ms); it exists only so a wedged compositor can't pin the session's stream thread.
const OP_BUDGET: Duration = Duration::from_secs(3);
/// Poll slice while waiting on the Wayland fd (matches the keepalive loop's cadence in `kwin.rs`).
const POLL_MS: i32 = 100;
/// KWin's CVT generator aligns a custom mode's width DOWN to a multiple of this (libxcvt's cell
/// grain), so the mode it builds for a `set_custom_modes` request may be a few px narrower than
/// asked — matches `kwin::CVT_H_GRANULARITY`. Used when matching the generated mode back.
const CVT_H_GRANULARITY: u32 = 8;
/// Which topology to apply once our output is resolved.
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum TopologyKind {
/// Make ours the sole desktop: primary + disable every other enabled output.
Exclusive,
/// Make ours primary but leave the other outputs enabled.
Primary,
}
/// Outcome of [`apply_topology`].
pub(crate) struct TopologyOutcome {
/// UUID of our resolved virtual output — a stable per-output id that survives a mode-switch
/// supersede (unlike the shared name) — for later [`set_position`] / restore addressing.
pub our_uuid: Option<String>,
/// The outputs we disabled, each `(name, "WxH@Hz")`, so teardown can restore them at their exact
/// mode. Empty for `Primary`, or when nothing else was enabled.
pub disabled: Vec<(String, String)>,
/// `true` if the in-process path bound management, resolved our output, and applied (or tried to)
/// a configuration. `false` ⇒ the compositor didn't answer in budget or our output never
/// appeared, so the caller should fall back to `kscreen-doctor`.
pub handled: bool,
}
/// One output as read from `kde_output_device_v2`.
#[derive(Default, Clone)]
struct DeviceState {
/// The global `name` number (higher = more recently advertised) — used to pick the newest of two
/// same-named outputs during a supersede.
global: u32,
name: Option<String>,
uuid: Option<String>,
enabled: bool,
/// KWin's output priority; 1 is the primary. `None` until the `priority` event (device ≥ v18).
priority: Option<u32>,
/// The `current_mode` object id; its size is looked up in [`State::mode_dims`].
current_mode: Option<ObjectId>,
/// Every mode this output advertised, in announce order — `(mode object id, proxy)` — so restore
/// can pick the one matching a captured `WxH@Hz`.
modes: Vec<(ObjectId, DeviceMode)>,
/// Set once this output's `done` burst has been seen (its state is coherent to read).
seen_done: bool,
proxy: Option<OutputDevice>,
}
/// Everything the enumerate/apply queue accumulates on one connection.
#[derive(Default)]
struct State {
management: Option<OutputManagement>,
mgmt_name_version: Option<(u32, u32)>,
devices: HashMap<ObjectId, DeviceState>,
/// mode object id → `(width, height, refresh_mHz)`.
mode_dims: HashMap<ObjectId, (u32, u32, u32)>,
/// Highest `wl_callback` serial whose `done` has arrived — the barrier the pump waits on.
sync_done: u32,
/// Configuration apply verdict: `Some(true)` = applied, `Some(false)` = failed.
applied: Option<bool>,
failure_reason: Option<String>,
}
impl Dispatch<WlRegistry, ()> for State {
fn event(
state: &mut Self,
registry: &WlRegistry,
event: wl_registry::Event,
_: &(),
_: &Connection,
qh: &QueueHandle<Self>,
) {
match event {
wl_registry::Event::Global {
name,
interface,
version,
} => {
if interface == OutputManagement::interface().name {
let v = version.min(MGMT_MAX);
state.management =
Some(registry.bind::<OutputManagement, _, _>(name, v, qh, ()));
state.mgmt_name_version = Some((name, v));
} else if interface == OutputDevice::interface().name {
let v = version.min(DEVICE_MAX);
// The device's `name` global carries into the device's UserData so the event
// handler can record it (newest-wins tie-break during a supersede).
let dev = registry.bind::<OutputDevice, _, _>(name, v, qh, name);
let id = dev.id();
state.devices.entry(id).or_default().proxy = Some(dev);
}
}
wl_registry::Event::GlobalRemove { .. } => {}
_ => {}
}
}
}
// Management has no events.
impl Dispatch<OutputManagement, ()> for State {
fn event(
_: &mut Self,
_: &OutputManagement,
_: management::kde_output_management_v2::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
// A client-built custom-mode list has no events; it just needs a Dispatch impl to be created.
impl Dispatch<ModeList, ()> for State {
fn event(
_: &mut Self,
_: &ModeList,
_: management::kde_mode_list_v2::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
/// The device's UserData is its global `name` number.
impl Dispatch<OutputDevice, u32> for State {
fn event(
state: &mut Self,
device: &OutputDevice,
event: DeviceEvent,
global: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
let entry = state.devices.entry(device.id()).or_default();
entry.global = *global;
if entry.proxy.is_none() {
entry.proxy = Some(device.clone());
}
match event {
DeviceEvent::Name { name } => entry.name = Some(name),
DeviceEvent::Uuid { uuid } => entry.uuid = Some(uuid),
DeviceEvent::Enabled { enabled } => entry.enabled = enabled != 0,
DeviceEvent::Priority { priority } => entry.priority = Some(priority),
DeviceEvent::CurrentMode { mode } => entry.current_mode = Some(mode.id()),
DeviceEvent::Mode { mode } => entry.modes.push((mode.id(), mode)),
DeviceEvent::Done => entry.seen_done = true,
_ => {}
}
}
// The `mode` event hands us a server-created `kde_output_device_mode_v2`. The opcode is a bare
// literal (the macro's fragment matcher rejects a `const` in some wayland-client versions); it is
// pinned to `DEVICE_MODE_EVENT_OPCODE` by `mode_event_opcode_is_two` below.
event_created_child!(State, OutputDevice, [
2 => (DeviceMode, ()),
]);
}
impl Dispatch<DeviceMode, ()> for State {
fn event(
state: &mut Self,
mode: &DeviceMode,
event: ModeEvent,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
let entry = state.mode_dims.entry(mode.id()).or_insert((0, 0, 0));
match event {
ModeEvent::Size { width, height } => {
entry.0 = width.max(0) as u32;
entry.1 = height.max(0) as u32;
}
ModeEvent::Refresh { refresh } => entry.2 = refresh.max(0) as u32,
_ => {}
}
}
}
impl Dispatch<OutputConfig, ()> for State {
fn event(
state: &mut Self,
_: &OutputConfig,
event: ConfigEvent,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
match event {
ConfigEvent::Applied => state.applied = Some(true),
ConfigEvent::Failed => state.applied = Some(false),
ConfigEvent::FailureReason { reason } => state.failure_reason = Some(reason),
_ => {}
}
}
}
impl Dispatch<WlCallback, u32> for State {
fn event(
state: &mut Self,
_: &WlCallback,
event: wl_callback::Event,
serial: &u32,
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wl_callback::Event::Done { .. } = event {
state.sync_done = state.sync_done.max(*serial);
}
}
}
/// A connected, bound output-management session on its own Wayland connection.
struct Session {
conn: Connection,
queue: wayland_client::EventQueue<State>,
state: State,
next_sync: u32,
}
impl Session {
/// Connect to the KWin Wayland socket, bind `kde_output_management_v2` + every
/// `kde_output_device_v2`, and read each output's state — all bounded by `OP_BUDGET`. `None` if
/// we can't connect, the management global isn't advertised, or the compositor doesn't answer in
/// budget (the wedge case — the caller then falls back to `kscreen-doctor`).
fn open() -> Option<Session> {
let conn = Connection::connect_to_env().ok()?;
let queue = conn.new_event_queue();
let qh = queue.handle();
let _registry = conn.display().get_registry(&qh, ());
let mut s = Session {
conn,
queue,
state: State::default(),
next_sync: 0,
};
let deadline = Instant::now() + OP_BUDGET;
// Phase 1: process the registry globals (binds management + every device in the handler).
if !s.sync_barrier(deadline) {
return None;
}
if s.state.management.is_none() {
tracing::debug!(
"KWin does not advertise kde_output_management_v2 to this client — kscreen-doctor \
fallback"
);
return None;
}
// Phase 2: flush the device binds issued in phase 1 and drain each output's state burst
// (name / enabled / priority / current_mode / mode sizes / done).
if !s.sync_barrier(deadline) {
return None;
}
Some(s)
}
/// Send a `wl_display.sync` and pump the queue until its `done` arrives or `deadline` passes.
/// Returns `true` on the barrier, `false` on timeout.
fn sync_barrier(&mut self, deadline: Instant) -> bool {
self.next_sync += 1;
let serial = self.next_sync;
let qh = self.queue.handle();
let _cb = self.conn.display().sync(&qh, serial);
self.pump_until(deadline, |st| st.sync_done >= serial)
}
/// Bounded manual event loop: flush, dispatch what's queued, then poll the connection fd for up
/// to `POLL_MS` and read. Mirrors the keepalive loop in `kwin.rs::run` (blocking_dispatch can't
/// be interrupted, so we poll the fd instead). Returns `true` once `done(&state)` holds.
fn pump_until(&mut self, deadline: Instant, done: impl Fn(&State) -> bool) -> bool {
loop {
if done(&self.state) {
return true;
}
if self.queue.dispatch_pending(&mut self.state).is_err() {
return false;
}
if done(&self.state) {
return true;
}
if Instant::now() >= deadline {
return false;
}
if self.conn.flush().is_err() {
return false;
}
let Some(guard) = self.conn.prepare_read() else {
continue; // events already queued — loop dispatches them
};
let mut pfd = libc::pollfd {
fd: self.conn.as_fd().as_raw_fd(),
events: libc::POLLIN,
revents: 0,
};
let remaining = deadline.saturating_duration_since(Instant::now());
let timeout = (remaining.as_millis() as i32).clamp(0, POLL_MS);
// SAFETY: `&mut pfd` points at one live, fully-initialized `libc::pollfd` on the stack and
// the count `1` matches that single element, so `poll` reads `fd`/`events` and writes
// `revents` strictly within `pfd`. `pfd.fd` is the Wayland connection's fd, valid because
// `self.conn` (and the `prepare_read` guard) outlive the call. `poll` blocks up to
// `timeout` ms and writes only `revents`; `pfd` is a fresh local that aliases nothing.
let r = unsafe { libc::poll(&mut pfd, 1, timeout) };
if r > 0 && (pfd.revents & libc::POLLIN) != 0 {
let _ = guard.read();
} // else: timeout/signal — drop the guard, re-check the deadline
}
}
/// A fresh `kde_output_configuration_v2` on this connection.
fn new_config(&self) -> OutputConfig {
let qh = self.queue.handle();
self.state
.management
.as_ref()
.unwrap()
.create_configuration(&qh, ())
}
/// A fresh (empty) `kde_mode_list_v2` on this connection, for a `set_custom_modes` request.
fn new_mode_list(&self) -> ModeList {
let qh = self.queue.handle();
self.state
.management
.as_ref()
.unwrap()
.create_mode_list(&qh, ())
}
/// `apply()` the config and pump until `applied`/`failed` or the deadline. Returns the verdict
/// (`true` applied, `false` failed/timeout).
fn apply(&mut self, config: &OutputConfig, deadline: Instant) -> bool {
self.state.applied = None;
config.apply();
let ok = self.pump_until(deadline, |st| st.applied.is_some());
if !ok {
return false;
}
matches!(self.state.applied, Some(true))
}
/// The current-mode size of a device as `(w, h, refresh_mHz)`, if known.
fn current_dims(&self, dev: &DeviceState) -> Option<(u32, u32, u32)> {
let id = dev.current_mode.as_ref()?;
self.state.mode_dims.get(id).copied()
}
}
/// `(width, height, "WxH@Hz")` capture of a device's current mode, Hz rounded — the same shape the
/// `kscreen-doctor` restore path used, so teardown can put a panel back at its real refresh.
fn mode_spec(dims: (u32, u32, u32)) -> String {
let hz = ((dims.2 as f64) / 1000.0).round() as u32;
format!("{}x{}@{}", dims.0, dims.1, hz)
}
/// Prefix EVERY managed KWin output shares (mirrors `kwin::MANAGED_PREFIX`) — the streamed outputs
/// are `Virtual-punktfunk` / `Virtual-punktfunk-<id>`, so a same-family sibling session is never
/// treated as a physical to disable, and its primary is never stolen (first-slot-wins).
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
/// Make the streamed output (name starts with `our_prefix`, current size `our_w`×`our_h`) the
/// primary — and, for `Exclusive`, disable every other enabled output — over `kde_output_management_v2`.
/// See the module docs for why this is done in-process instead of via `kscreen-doctor`.
pub(crate) fn apply_topology(
our_prefix: &str,
our_w: u32,
our_h: u32,
kind: TopologyKind,
) -> TopologyOutcome {
let miss = || TopologyOutcome {
our_uuid: None,
disabled: Vec::new(),
handled: false,
};
let Some(mut sess) = Session::open() else {
return miss();
};
let deadline = Instant::now() + OP_BUDGET;
// Resolve OUR output: managed-prefix name AND current size == the birth size (only the
// just-created output sits there during a supersede); newest global wins the tie.
let ours = sess
.state
.devices
.values()
.filter(|d| {
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((our_w, our_h))
})
.max_by_key(|d| d.global)
.cloned();
let Some(ours) = ours else {
tracing::warn!(
our_prefix,
our_w,
our_h,
"KWin output management: our virtual output hasn't appeared yet — kscreen-doctor fallback"
);
return miss();
};
let our_uuid = ours.uuid.clone();
let our_id = ours.proxy.as_ref().map(|p| p.id());
// First-slot-wins (§6.1): don't steal primary if another managed sibling already holds it
// (priority 1) — a 2nd exclusive session joins as a secondary of the shared desktop.
let sibling_is_primary = sess.state.devices.values().any(|d| {
d.enabled
&& d.priority == Some(1)
&& d.proxy.as_ref().map(|p| p.id()) != our_id
&& d.name
.as_deref()
.is_some_and(|n| n.starts_with(MANAGED_PREFIX))
});
// The physical/bootstrap outputs to disable for `Exclusive`: enabled, not any managed sibling,
// not ours. Captured WITH their current mode so teardown restores the exact refresh.
let mut to_disable: Vec<(OutputDevice, String, String)> = Vec::new();
if kind == TopologyKind::Exclusive {
for d in sess.state.devices.values() {
let is_ours = d.proxy.as_ref().map(|p| p.id()) == our_id;
let managed = d
.name
.as_deref()
.is_some_and(|n| n.starts_with(MANAGED_PREFIX));
if d.enabled && !is_ours && !managed {
if let (Some(name), Some(proxy)) = (d.name.clone(), d.proxy.clone()) {
let spec = sess.current_dims(d).map(mode_spec).unwrap_or_default();
to_disable.push((proxy, name, spec));
}
}
}
}
// Build one configuration: ensure ours is enabled, take primary (unless a sibling holds it),
// disable the others. `apply()` is atomic — KWin re-homes the shell onto the remaining desktop.
let config = sess.new_config();
if let Some(proxy) = ours.proxy.as_ref() {
config.enable(proxy, 1);
if !sibling_is_primary {
config.set_primary_output(proxy);
}
}
for (proxy, _, _) in &to_disable {
config.enable(proxy, 0);
}
let applied = sess.apply(&config, deadline);
config.destroy();
// Always report the outputs we ASKED to disable so teardown restores them: re-enabling an output
// that never actually got disabled is a harmless no-op, whereas dropping them here would strand a
// physical dark if KWin processed the disable but the `applied` ack didn't land in budget.
let disabled: Vec<(String, String)> = to_disable
.into_iter()
.map(|(_, name, spec)| (name, spec))
.collect();
if applied {
tracing::info!(
also_disabled = ?disabled,
primary_taken = !sibling_is_primary,
"KWin output management: streamed output set as the desktop (in-process)"
);
} else {
tracing::warn!(
reason = ?sess.state.failure_reason,
also_disabled = ?disabled,
"KWin output management: apply() not confirmed in budget — proceeding (restore will re-enable)"
);
}
// We resolved our output and drove the config over Wayland; don't ALSO run kscreen-doctor — that
// would double-apply (and on a wedged box it would just add 26 s of timeouts). `handled` is true
// even on an unconfirmed apply; a genuinely absent management global / unresolved output took the
// `handled = false` early returns above and falls back to kscreen-doctor.
TopologyOutcome {
our_uuid,
disabled,
handled: true,
}
}
/// Install + select a `want_w`×`want_h`@`want_hz` custom mode on the just-created virtual output
/// (name starts with `our_prefix`, currently at its sacrificial birth size `birth_w`×`birth_h`) —
/// entirely over `kde_output_management_v2`, the in-process replacement for the `kscreen-doctor`
/// `addCustomMode` + `mode` shell-out (`set_custom_refresh`).
///
/// `set_custom_modes` hands KWin a one-entry mode list; KWin generates the CVT timing (so the width
/// may align DOWN — see [`CVT_H_GRANULARITY`]) and adds the mode. We then SELECT it, which changes
/// the output's size and triggers the screencast stream's renegotiation to the real refresh (the
/// sacrificial-birth mechanism in `kwin::create`). Returns the ACTIVE mode read back after selection
/// (Hz rounded), or `None` if management is absent, the output/generated mode never appeared, or an
/// apply didn't confirm — the caller then falls back to `kscreen-doctor`. `set_custom_modes` REPLACES
/// the custom list (idempotent across reconnects — no per-connect list growth), and it is `since 18`,
/// so pre-6.6 KWin without it simply takes the `None` → kscreen-doctor path.
pub(crate) fn set_custom_mode(
our_prefix: &str,
birth_w: u32,
birth_h: u32,
want_w: u32,
want_h: u32,
want_hz: u32,
) -> Option<(u32, u32, u32)> {
let mut sess = Session::open()?;
let deadline = Instant::now() + OP_BUDGET;
// `set_custom_modes` is `since 18`; calling it on an older bound management object is a protocol
// error, so bail to the kscreen-doctor fallback there (pre-6.6 KWin). Our bound version is
// `min(advertised, MGMT_MAX)`.
if sess.state.mgmt_name_version.map(|(_, v)| v).unwrap_or(0) < 18 {
return None;
}
// Resolve our output at its birth size (newest global wins a supersede).
let our_proxy = sess
.state
.devices
.values()
.filter(|d| {
d.name.as_deref().is_some_and(|n| n.starts_with(our_prefix))
&& sess.current_dims(d).map(|(w, h, _)| (w, h)) == Some((birth_w, birth_h))
})
.max_by_key(|d| d.global)
.and_then(|d| d.proxy.clone())?;
let our_key = our_proxy.id();
let want_mhz = want_hz.saturating_mul(1000);
// A generated mode IS our custom one iff: exact height, width at/just-below the request (a CVT
// alignment), and refresh within 1 Hz — which excludes the sacrificial 60 Hz birth mode.
let mode_matches = move |st: &State, mid: &ObjectId| -> bool {
st.mode_dims.get(mid).is_some_and(|&(w, h, mhz)| {
h == want_h
&& w <= want_w
&& want_w - w < CVT_H_GRANULARITY
&& (mhz as i64 - want_mhz as i64).abs() <= 1000
})
};
// Build a one-entry custom-mode list (full blanking, like kscreen-doctor's `.full`) and install it.
let mode_list = sess.new_mode_list();
mode_list.set_resolution(want_w, want_h);
mode_list.set_refresh_rate(want_mhz);
mode_list.set_reduced_blanking(0);
mode_list.add_mode();
let config = sess.new_config();
config.set_custom_modes(&our_proxy, &mode_list);
let installed = sess.apply(&config, deadline);
config.destroy();
mode_list.destroy();
if !installed {
return None;
}
// Wait for KWin to generate the mode and advertise it on the output.
let found = |st: &State| -> bool {
st.devices
.get(&our_key)
.is_some_and(|d| d.modes.iter().any(|(mid, _)| mode_matches(st, mid)))
};
if !sess.pump_until(deadline, found) {
tracing::warn!(
want_w,
want_h,
want_hz,
"KWin output management: generated custom mode never appeared — kscreen-doctor fallback"
);
return None;
}
// Grab the generated mode's proxy, then select it (this is what changes the size).
let mode_proxy = {
let dev = sess.state.devices.get(&our_key)?;
dev.modes
.iter()
.find(|(mid, _)| mode_matches(&sess.state, mid))
.map(|(_, p)| p.clone())?
};
let config = sess.new_config();
config.mode(&our_proxy, &mode_proxy);
let selected = sess.apply(&config, deadline);
config.destroy();
if !selected {
return None;
}
// Read back the active mode after selection — the size that really landed paces the encoder.
let want_dims = sess
.state
.mode_dims
.get(&mode_proxy.id())
.map(|&(w, h, _)| (w, h));
let landed = |st: &State| -> bool {
st.devices
.get(&our_key)
.and_then(|d| d.current_mode.as_ref())
.and_then(|mid| st.mode_dims.get(mid))
.map(|&(w, h, _)| (w, h))
== want_dims
};
sess.pump_until(deadline, landed);
let dev = sess.state.devices.get(&our_key)?;
let (cw, ch, cmhz) = sess.current_dims(dev)?;
let hz = ((cmhz as f64) / 1000.0).round() as u32;
tracing::info!(
want_w,
want_h,
want_hz,
active_w = cw,
active_h = ch,
active_hz = hz,
"KWin output management: custom mode installed + selected (in-process)"
);
Some((cw, ch, hz.max(1)))
}
/// Re-enable outputs by name at their captured `WxH@Hz` modes (teardown), in-process. Returns
/// `true` if the config applied; `false` (compositor unresponsive / management absent) tells the
/// caller to fall back to `kscreen-doctor`.
pub(crate) fn reenable_outputs(outputs: &[(String, String)]) -> bool {
if outputs.is_empty() {
return true;
}
let Some(mut sess) = Session::open() else {
return false;
};
let deadline = Instant::now() + OP_BUDGET;
let config = sess.new_config();
for (name, spec) in outputs {
// Find the device by name (physical names are stable across a session).
let Some(dev) = sess
.state
.devices
.values()
.find(|d| d.name.as_deref() == Some(name.as_str()))
.cloned()
else {
continue;
};
let Some(proxy) = dev.proxy.as_ref() else {
continue;
};
// Enable first — a bare enable always succeeds, so a physical is never left dark.
config.enable(proxy, 1);
// Then re-assert the captured mode so a 120 Hz panel doesn't return at KWin's ~60 Hz default.
if let Some(mode) = find_mode(&sess, &dev, spec) {
config.mode(proxy, &mode);
}
}
let ok = sess.apply(&config, deadline);
config.destroy();
if ok {
tracing::info!(reenabled = ?outputs, "KWin output management: restored outputs (in-process)");
}
ok
}
/// Position the output identified by `uuid` at `(x, y)` in the desktop layout, in-process. Returns
/// `true` if applied; `false` tells the caller to fall back to `kscreen-doctor`.
pub(crate) fn set_position(uuid: &str, x: i32, y: i32) -> bool {
let Some(mut sess) = Session::open() else {
return false;
};
let deadline = Instant::now() + OP_BUDGET;
let Some(dev) = sess
.state
.devices
.values()
.find(|d| d.uuid.as_deref() == Some(uuid))
.cloned()
else {
return false;
};
let Some(proxy) = dev.proxy.as_ref() else {
return false;
};
let config = sess.new_config();
config.position(proxy, x, y);
let ok = sess.apply(&config, deadline);
config.destroy();
if ok {
tracing::info!(
uuid,
x,
y,
"KWin output management: placed output (in-process)"
);
}
ok
}
/// Find a device's advertised mode proxy matching a captured `"WxH@Hz"` spec (Hz rounded), for
/// restore. `None` if the spec is empty or no mode matches (the caller then enables without a mode
/// and lets KWin pick its preferred one).
fn find_mode(sess: &Session, dev: &DeviceState, spec: &str) -> Option<DeviceMode> {
if spec.is_empty() {
return None;
}
let (wh, hz) = spec.split_once('@')?;
let (w, h) = wh.split_once('x')?;
let (w, h, hz): (u32, u32, u32) = (w.parse().ok()?, h.parse().ok()?, hz.parse().ok()?);
dev.modes.iter().find_map(|(id, proxy)| {
let (mw, mh, mmhz) = sess.state.mode_dims.get(id).copied()?;
let mhz = ((mmhz as f64) / 1000.0).round() as u32;
((mw, mh, mhz) == (w, h, hz)).then(|| proxy.clone())
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The `WxH@Hz` capture rounds mHz to whole Hz — the shape teardown parses back.
#[test]
fn mode_spec_rounds_millihertz() {
assert_eq!(mode_spec((2560, 1440, 59940)), "2560x1440@60");
assert_eq!(mode_spec((1920, 1080, 60000)), "1920x1080@60");
assert_eq!(mode_spec((3840, 2160, 119880)), "3840x2160@120");
}
/// The vendored device XML must keep `mode` at the opcode the `event_created_child!` macro
/// hardcodes — a reorder there would bind the child to the wrong event and desync mode sizes.
#[test]
fn mode_event_opcode_is_two() {
assert_eq!(DEVICE_MODE_EVENT_OPCODE, 2);
}
}
+97 -7
View File
@@ -939,7 +939,7 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
// live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop // live topology each attempt and re-apply until ONLY the keep set is active. Secure-desktop
// correctness depends on this — the lock screen must not land on a stray panel while we stream. // correctness depends on this — the lock screen must not land on a stray panel while we stream.
for attempt in 1..=4u32 { for attempt in 1..=4u32 {
let (mut paths, modes) = query_active_config()?; let (mut paths, mut modes) = query_active_config()?;
let mut others = 0u32; let mut others = 0u32;
for p in paths.iter_mut() { for p in paths.iter_mut() {
if keep_target_ids.contains(&p.targetInfo.id) { if keep_target_ids.contains(&p.targetInfo.id) {
@@ -961,6 +961,13 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
others += 1; others += 1;
} }
} }
// The doomed display may have HELD the desktop origin (the physical is primary in the
// single-slot exclusive case): re-anchor the kept sources so the supplied config still
// contains a primary — an origin-less desktop is rejected 0x57 no matter which array
// shape carries it (see `anchor_kept_sources_at_origin`).
if others > 0 {
anchor_kept_sources_at_origin(&paths, &mut modes);
}
// Commit the config. Even when nothing needed deactivating we re-commit: a legacy mode-set does // Commit the config. Even when nothing needed deactivating we re-commit: a legacy mode-set does
// NOT drive the IddCx adapter's EVT_IDD_CX_ADAPTER_COMMIT_MODES, and without COMMIT_MODES the OS // NOT drive the IddCx adapter's EVT_IDD_CX_ADAPTER_COMMIT_MODES, and without COMMIT_MODES the OS
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION // never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
@@ -969,11 +976,11 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
// The supplied shape is decided (and its escalation announced) ONCE, outside the write, so a // The supplied shape is decided (and its escalation announced) ONCE, outside the write, so a
// wrong-desktop retry re-issues the identical config instead of logging the escalation twice. // wrong-desktop retry re-issues the identical config instead of logging the escalation twice.
let keep_only = (others > 0 && attempt >= 2).then(|| { let keep_only = (others > 0 && attempt >= 2).then(|| {
// ESCALATION (attempt 2+): supply ONLY the keep paths. Field-reported (AMD + // ESCALATION (attempt 2+): supply ONLY the keep paths. Kept as belt-and-braces —
// pf-vdisplay): carrying the doomed path in the array — inactive, modes unpinned — // the field 0x57 this was built for turned out to be the missing desktop origin
// gets the whole config rejected 0x57 on EVERY retry, so the loop alone never // (see `anchor_kept_sources_at_origin`), which rejected BOTH shapes identically;
// converged; the same host applies the keep-only shape rc=0 whenever the topology // but should some other validator still choke on the full array, the minimal
// database has already made the virtual display sole. The final attempt also drops // shape is the best last word. The final attempt also drops
// SDC_FORCE_MODE_ENUMERATION in case the driver rejects it combined with a real // SDC_FORCE_MODE_ENUMERATION in case the driver rejects it combined with a real
// topology change — an actual path removal drives COMMIT_MODES on its own, so the // topology change — an actual path removal drives COMMIT_MODES on its own, so the
// re-commit rationale doesn't need the flag here. // re-commit rationale doesn't need the flag here.
@@ -1023,7 +1030,18 @@ pub unsafe fn isolate_displays_ccd(keep_target_ids: &[u32]) -> Option<SavedConfi
tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying"); tracing::warn!("display isolate (CCD): {survivors} display(s) STILL active after attempt {attempt}/4 (deactivated {others}, rc={rc:#x}) — re-querying + retrying");
std::thread::sleep(std::time::Duration::from_millis(250)); std::thread::sleep(std::time::Duration::from_millis(250));
} }
tracing::error!("display isolate (CCD): failed to isolate target set {keep_target_ids:?} after 4 attempts — a non-virtual display stayed active (field-reported exclusive-mode bug)"); // Name the survivors instead of assuming their kind — the field logs showed this path fire
// with a sibling VIRTUAL display as the survivor (linger-expiry shrink), where the old
// "a non-virtual display stayed active" wording sent the triage the wrong way.
let survivors: Vec<String> = target_inventory()
.iter()
.filter(|t| t.active && !keep_target_ids.contains(&t.target_id))
.map(|t| format!("{} {} \"{}\"", t.target_id, t.tech, t.friendly))
.collect();
tracing::error!(
"display isolate (CCD): failed to isolate target set {keep_target_ids:?} after 4 attempts — still active: [{}] (field-reported exclusive-mode bug)",
survivors.join(", ")
);
Some(saved) Some(saved)
} }
@@ -1084,6 +1102,60 @@ fn remap_mode_idx(
}) })
} }
/// A committable desktop must still contain a PRIMARY — a source pinned exactly at the origin
/// `(0,0)`. Deactivating the display that held the origin (the physical, in the exclusive
/// topology) while the kept virtual stays pinned at its EXTEND offset supplies an origin-less
/// desktop, and Windows rejects that wholesale with 0x57 ERROR_INVALID_PARAMETER no matter the
/// array shape — the field box failed identically with the doomed path carried inactive AND with
/// the keep-only escalation, yet the very same call converged rc=0 whenever a kept member already
/// sat at `(0,0)`; the origin was the real variable all along. Translate the kept sources RIGIDLY
/// (relative arrangement preserved) so the top-left-most lands exactly on the origin. A set that
/// already covers `(0,0)` is left untouched, so a plain re-commit stays byte-identical and a
/// user's negative-coordinate multi-monitor layout is never rearranged.
unsafe fn anchor_kept_sources_at_origin(
paths: &[DISPLAYCONFIG_PATH_INFO],
modes: &mut [DISPLAYCONFIG_MODE_INFO],
) {
// Unique source-mode entries of the still-ACTIVE (kept) paths — clone-style pairs share one,
// and a shared entry must be translated once.
let mut idxs: Vec<usize> = Vec::new();
for p in paths {
if p.flags & DISPLAYCONFIG_PATH_ACTIVE == 0 {
continue;
}
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
let Some(m) = modes.get(idx) else { continue };
if m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE && !idxs.contains(&idx) {
idxs.push(idx);
}
}
let positions: Vec<(i32, i32)> = idxs
.iter()
.map(|&i| {
let pos = modes[i].Anonymous.sourceMode.position;
(pos.x, pos.y)
})
.collect();
if positions.contains(&(0, 0)) {
return; // the kept set already holds the primary — don't touch a valid layout
}
// Lexicographic min over the actual positions — the anchor IS one of the kept sources, so
// after translation one source sits exactly at (0,0), which is what the validator wants.
let Some((ax, ay)) = positions.iter().copied().min() else {
return; // no pinned kept sources — placement is the OS's (SDC_ALLOW_CHANGES), nothing to anchor
};
for &i in &idxs {
let sm = &mut modes[i].Anonymous.sourceMode;
sm.position.x -= ax;
sm.position.y -= ay;
}
tracing::info!(
"display isolate (CCD): kept source(s) re-anchored onto the desktop origin (primary) — the doomed display held (0,0) delta=({},{})",
-ax,
-ay
);
}
/// The desktop-space rectangle `(x, y, w, h)` of `target_id`'s SOURCE — where this display's /// The desktop-space rectangle `(x, y, w, h)` of `target_id`'s SOURCE — where this display's
/// region lives in the desktop coordinate space. `None` while the target isn't an active path. /// region lives in the desktop coordinate space. `None` while the target isn't an active path.
/// Used by the IDD-push compose kick to dirty THE TARGET display: with parallel displays the /// Used by the IDD-push compose kick to dirty THE TARGET display: with parallel displays the
@@ -1316,4 +1388,22 @@ pub unsafe fn restore_displays_ccd(saved: &SavedConfig) {
sdc_access_denied_hint(rc) sdc_access_denied_hint(rc)
); );
} }
// GUARANTEE the desk is never left all-dark. The saved config can be unappliable (field
// rc=0x64a ERROR_BAD_CONFIGURATION: it pinned a virtual target incarnation that was since
// removed) or even apply rc=0 yet re-light nothing (snapshotted while an earlier failed
// teardown had the physicals off — the poisoned-snapshot chain from the field logs). Either
// way, if no external physical panel is active after the apply while at least one is
// connected, fall back to the OS database EXTEND preset, which re-activates every connected
// display. Internal panels deliberately don't count as lit-able here — a closed clamshell
// lid must not be forced back on.
let (connected, lit) = target_inventory()
.iter()
.filter(|t| t.external_physical)
.fold((0u32, 0u32), |(c, a), t| (c + 1, a + u32::from(t.active)));
if connected > 0 && lit == 0 {
tracing::warn!(
"display isolate (CCD): no external physical display active after the restore (rc={rc:#x}, connected={connected}) — forcing the EXTEND preset so the desk is not left dark"
);
force_extend_topology();
}
} }
+5
View File
@@ -124,6 +124,11 @@ pub fn capture_virtual_output(
virtual-display warnings above)" virtual-display warnings above)"
) )
})?; })?;
// Aim the injectors' absolute mapping (pen/touch/abs-mouse) at THIS display: the wire
// normalizes over the streamed frame, and mapping it over the whole virtual desktop is wrong
// the moment a physical monitor shares the desktop (Extend topology, or an Exclusive isolate
// degraded to the keep-physicals fallback) — the pen-offset field bug.
crate::inject::set_stream_target(Some(target.target_id));
let pref = vout.preferred_mode; let pref = vout.preferred_mode;
let keep = vout.keepalive; let keep = vout.keepalive;
// The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE (it is // The sealed-channel delivery seam: resolve the pf-vdisplay control device ONCE (it is
+33 -2
View File
@@ -1940,6 +1940,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// connected, frozen on the last frame, and the stream resumes when the new output // connected, frozen on the last frame, and the stream resumes when the new output
// appears — no reconnect. // appears — no reconnect.
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40); const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
// A managed/attach gamescope (re)launch legitimately takes up to 45 s — the Steam
// Big Picture cold start that `launch_session`/`ensure_box_gamescope_mode` poll
// for — so the 40 s budget used to expire INSIDE the first attempt (a single-shot
// failure ending the session even when a second, warm attempt would have
// succeeded). Give gamescope-targeted rebuilds room for two full launch attempts;
// desktop compositors keep the tighter budget. Checked per iteration because the
// loop retargets `compositor` as re-detection follows the box.
const GAMESCOPE_REBUILD_BUDGET: std::time::Duration =
std::time::Duration::from_secs(100);
// Attach-only holdoff: for the first seconds after a capture loss the session // Attach-only holdoff: for the first seconds after a capture loss the session
// detection can be STALE (the new session isn't up yet), and a rebuild acting on // detection can be STALE (the new session isn't up yet), and a rebuild acting on
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS // a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
@@ -1948,7 +1957,24 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
// scope: attach to live outputs only, never stop/relaunch/take over sessions. // scope: attach to live outputs only, never stop/relaunch/take over sessions.
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4); const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
let loss_at = std::time::Instant::now(); let loss_at = std::time::Instant::now();
let rebuild_deadline = loss_at + REBUILD_BUDGET; // An explicit PUNKTFUNK_COMPOSITOR pin disables the re-detection below — the
// stream cannot follow a session switch. When the live session no longer matches
// the pin, say so loudly ONCE per loss: this rebuild can only retry the pinned
// backend and will die at the budget (the "mid-stream switch to game mode kills
// the stream" field reports all traced back to a stale pin).
if pf_host_config::config().compositor.is_some() {
let active = crate::vdisplay::detect_active_session();
if crate::vdisplay::compositor_for_kind(active.kind) != Some(compositor) {
tracing::warn!(
pinned = compositor.id(),
live = ?active.kind,
"capture lost while PUNKTFUNK_COMPOSITOR pins the backend and the \
live session no longer matches it the pin disables \
session-following, so this rebuild can only retry the pinned \
backend; remove the pin to let the stream follow session switches"
);
}
}
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop { let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids // Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
// retargeting (then we stick to the pinned backend and just rebuild it). // retargeting (then we stick to the pinned backend and just rebuild it).
@@ -2021,8 +2047,13 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
) { ) {
Ok(p) => break p, Ok(p) => break p,
Err(e2) => { Err(e2) => {
let budget = if compositor == crate::vdisplay::Compositor::Gamescope {
GAMESCOPE_REBUILD_BUDGET
} else {
REBUILD_BUDGET
};
if stop.load(Ordering::SeqCst) if stop.load(Ordering::SeqCst)
|| std::time::Instant::now() >= rebuild_deadline || std::time::Instant::now() >= loss_at + budget
{ {
return Err(e2) return Err(e2)
.context("capture lost — no compositor came up within the rebuild budget"); .context("capture lost — no compositor came up within the rebuild budget");
+3 -2
View File
@@ -100,8 +100,9 @@ For Gaming Mode there are two models (pick one; the shipped default is **attach*
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the template's default) — the **box** owns its - **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`, the template's default) — the **box** owns its
gamescope session on its own display, and the host attaches to whatever's live without ever gamescope session on its own display, and the host attaches to whatever's live without ever
tearing it down (a box-owned autologin session is restarted at the client's resolution on a tearing it down (on a headless box, a box-owned autologin session is restarted at the client's
mismatch). Switching Desktop ↔ Game is rock-solid. resolution on a mismatch; with a display connected it streams at the box's own mode). Switching
Desktop ↔ Game is rock-solid.
- **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host takes the - **Managed** (`PUNKTFUNK_GAMESCOPE_MANAGED=1`, and remove the attach line) — the host takes the
box's gamescope over and relaunches it **headless** at the *client's* exact resolution and box's gamescope over and relaunches it **headless** at the *client's* exact resolution and
refresh — Game Mode on the virtual screen — restoring the box on idle. refresh — Game Mode on the virtual screen — restoring the box on idle.
+1 -1
View File
@@ -53,7 +53,7 @@ the full picture (and [Bazzite](/docs/bazzite) for that distro's specifics).
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. A box-owned autologin session is restarted at the client's resolution on a mismatch; a foreign/bare gamescope streams at its own mode. | | `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session on its own display (you switch Gaming ↔ Desktop with the Steam UI); the host just captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. |
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it **headless** at the *client's* exact resolution — Game Mode on the virtual screen — restoring the box on idle. | | `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it **headless** at the *client's* exact resolution — Game Mode on the virtual screen — restoring the box on idle. |
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). | | `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode (headless appliance; no physical session running). |
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. | | `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover + capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
+36 -4
View File
@@ -24,14 +24,46 @@ that has gamescope session infrastructure (Bazzite, SteamOS, Nobara) gets **mana
- **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session and decides - **Attach** (`PUNKTFUNK_GAMESCOPE_ATTACH=1`) — the **box** owns its gamescope session and decides
Gaming vs Desktop via the normal Steam UI. Game Mode stays on the box's own (physical) display; Gaming vs Desktop via the normal Steam UI. Game Mode stays on the box's own (physical) display;
the host attaches to whatever's live and never tears it down, so switching Desktop ↔ Game is the host attaches to whatever's live and never tears it down, so switching Desktop ↔ Game is
rock-solid and disconnecting leaves the box where it was. When the session is the box's own rock-solid and disconnecting leaves the box where it was. When the box is **headless** (no
autologin unit, the host restarts it at the **client's** resolution on a mismatch; a foreign or display connected) and the session is its own autologin unit, the host restarts it at the
bare gamescope is streamed at its own mode. **client's** resolution on a mismatch; a box driving a physical display — and any foreign or
bare gamescope — is streamed at its own mode.
- **Managed** (the infra-detected default; force with `PUNKTFUNK_GAMESCOPE_MANAGED=1`) — the host - **Managed** (the infra-detected default; force with `PUNKTFUNK_GAMESCOPE_MANAGED=1`) — the host
takes the box's gamescope session over and relaunches it **headless** at the *client's* exact takes the box's gamescope session over and relaunches it **headless** at the *client's* exact
resolution and refresh — Game Mode runs on the virtual screen, physical displays drop out of it — resolution and refresh — Game Mode runs on the virtual screen, physical displays drop out of it —
restoring the box on idle after disconnect. restoring the box on idle after disconnect.
### Nobara and other autologin display managers
The managed takeover has to stop the box's Gaming Mode session to free Steam. How it does that
depends on the display manager driving the autologin:
- **SDDM** (Bazzite, SteamOS): handled automatically — no setup.
- **plasmalogin** (Nobara) and other display managers: the host must stop the display manager
itself for the length of the stream and restart it afterwards, which needs privilege. Allow it
with a polkit rule (adjust the unit and user names to your box):
```js
// /etc/polkit-1/rules.d/49-punktfunk-dm.rules
polkit.addRule(function(action, subject) {
if (action.id == "org.freedesktop.systemd1.manage-units" &&
action.lookup("unit") == "plasmalogin.service" &&
subject.user == "YOUR_USER") {
return polkit.Result.YES;
}
});
```
Without the rule the host degrades safely: it **attaches** to the live Gaming Mode session
instead (Game Mode stays on the box's display, mirrored to the client) rather than risk the
display manager. If the display-manager restart ever loses its privilege mid-restore,
`PUNKTFUNK_RECOVER_SESSION_CMD` (see [Configuration](/docs/configuration)) is fired as the
fallback.
With the rule in place the **in-stream session switch round-trips** in managed mode: Steam's
"Switch to Desktop" inside the streamed Game Mode returns the box to its desktop session and the
stream follows it there; the desktop's "Return to Gaming Mode" switches it forward again.
## Session following ## Session following
`PUNKTFUNK_SESSION_WATCH` follows a Gaming ↔ Desktop switch **mid-stream** — the host rebuilds the `PUNKTFUNK_SESSION_WATCH` follows a Gaming ↔ Desktop switch **mid-stream** — the host rebuilds the
@@ -58,7 +90,7 @@ a model. See the full [Configuration reference](/docs/configuration) for every o
| Setting | Values | Meaning | | Setting | Values | Meaning |
|---|---|---| |---|---|---|
| `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session (on its own display); the host captures whatever's live and never tears it down. A box-owned autologin session is restarted at the client's resolution on a mismatch; a foreign/bare gamescope streams at its own mode. | | `PUNKTFUNK_GAMESCOPE_ATTACH` | `1` | **Attach** model: the box owns its gamescope session (on its own display); the host captures whatever's live and never tears it down. On a **headless** box the box-owned autologin session is restarted at the client's resolution on a mismatch; a box driving a physical display, and any foreign/bare gamescope, streams at its own mode. |
| `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it headless at the client's exact mode, restoring on idle. | | `PUNKTFUNK_GAMESCOPE_MANAGED` | `1` | **Managed** model (the default where session infra is detected): the host takes the box's gamescope over and relaunches it headless at the client's exact mode, restoring on idle. |
| `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode — a headless appliance with no physical session running. | | `PUNKTFUNK_GAMESCOPE_SESSION` | `steam` | The host owns a `gamescope-session-plus` (Steam) session at the client's mode — a headless appliance with no physical session running. |
| `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover and capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. | | `PUNKTFUNK_GAMESCOPE_NODE` | `auto` · node id | Discover and capture a **running** gamescope's PipeWire node at a fixed mode. Do **not** combine with `SESSION`. |
+15
View File
@@ -107,6 +107,21 @@ See [GNOME](/docs/gnome) for the GL/EGL userspace details.
auto-detects the live compositor, and the pin points it at one backend even when a different auto-detects the live compositor, and the pin points it at one backend even when a different
session is live (it also disables Gaming ↔ Desktop following). session is live (it also disables Gaming ↔ Desktop following).
## The screen stays black after switching to Game Mode (Nobara)
On distros whose Game Mode is display-manager autologin under **plasmalogin** (Nobara), a managed
takeover from a host **0.19.1 or older** could kill the display manager: it trips systemd's start
limit and the box stays black until someone restarts it. Recover from a VT (Ctrl+Alt+F3) or SSH:
```sh
systemctl --user unmask --runtime 'gamescope-session-plus@*.service'
sudo systemctl reset-failed plasmalogin && sudo systemctl restart plasmalogin
```
Current hosts detect the display-manager flavor and never mask the session unit there — see
[gamescope → autologin display managers](/docs/gamescope) for the polkit rule that enables the full
managed takeover on these boxes (without it the host mirrors Game Mode instead).
## Session fails right after editing host.env ## Session fails right after editing host.env
- Keys are **case-sensitive**: `punktfunk_gamescope_attach=1` sets nothing — use the exact - Keys are **case-sensitive**: `punktfunk_gamescope_attach=1` sets nothing — use the exact
+46
View File
@@ -0,0 +1,46 @@
A small fixes update, all on the machine you stream *from* — your apps don't change, you can update whenever, and everything you've already paired stays paired.
This one cleans up a handful of things that went wrong on specific setups. On a **KDE Plasma** desktop, streaming now properly takes over the screen — switching off the real monitors and handing you the streamed display — and comes up in a couple of seconds instead of stalling, even on machines where KDE's own display tools had stopped responding. On a **Linux** machine you switch into **Steam's Game Mode**, that switch no longer risks leaving the machine stuck on a black screen, and you can now switch back to the desktop from inside the stream. And on **Windows**, giving the stream its own dedicated display is reliable again and can no longer leave the real desktop dark — and pen, touch and absolute-position mouse now land where you point when a second monitor is in play.
## Fixed: streaming from a KDE Plasma desktop takes over the screen — and comes up fast
On some KDE Plasma machines — a Nobara media PC among them — connecting used to *look* like it worked but leave the real monitors on and never actually hand you the streamed display, after a long wait of around half a minute. The cause was KDE's own display tooling hanging on that machine, with Punktfunk waiting on it.
Punktfunk now sets the machine's displays up by talking to KDE directly instead of going through that tool:
- **The streamed display actually becomes the desktop.** The physical monitors switch off for the session and come back when you disconnect — reliably, and typically in a couple of seconds instead of half a minute.
- **It works even when KDE's display settings tool is unresponsive** — the exact case that used to break connecting entirely.
- **High refresh rates work on these machines too.** The 90/120 Hz path no longer leans on that same tool. (Machines where the tool was healthy already worked; this just removes the dependency.)
## Fixed: switching a Linux machine into Steam's Game Mode no longer breaks it
Switching a Linux desktop into Steam's Game Mode mid-stream could leave the whole machine on a **permanent black screen** — on desktops that don't use the same sign-in manager as a Steam Deck (a Nobara home-theatre PC was the report). Steam Deck-style machines were never affected.
The switch is safe now: Punktfunk hands the screen to Game Mode without taking down the machine's sign-in manager, and puts everything back when you disconnect. On a machine where it doesn't have permission to do that cleanly, it mirrors whatever Game Mode is already showing rather than risk the screen — so you always get a picture instead of a black one.
## New: switch back to the desktop from inside the stream
On a Linux machine that Punktfunk put into Game Mode for you, choosing **Switch to Desktop** from Steam's menu used to do nothing — it just dropped you straight back into Game Mode. It now actually returns the machine to its desktop, and the stream follows it there.
## Fixed: giving the stream its own display on Windows is reliable again — and never goes dark
The **experimental option that isolates the stream onto its own display** on Windows (switching off the physical monitors) could fail outright on some monitor layouts, and a failed attempt could leave the real desktop on a black screen.
- **It works across monitor layouts now**, not only when a physical monitor happened to sit in the top-left corner of the desktop.
- **The desktop is never left dark.** If restoring your original layout can't light a screen back up, Punktfunk falls back to a normal extended-desktop arrangement, so you're never stranded looking at black.
## Fixed: pen, touch and absolute mouse land where you point on a multi-monitor Windows machine
When you stream from a Windows machine that keeps a **physical monitor on alongside** the streamed display, pen, touch, and absolute-position mouse input landed in the **wrong spot** — shifted across and scaled wrong, so a stylus drew where you weren't pointing. A single-display setup was never affected; the pen showed it first, because a stylus is pure point-where-you-touch with nothing to correct it.
Input now maps onto the streamed display itself, so **the pen lands exactly where you touch** — and it keeps up if you rearrange the machine's monitors mid-session.
## Under the hood (for developers)
- **Nothing changed on the wire or at the API boundary.** The streaming protocol stays at version **2** and the embeddable core library stays at C ABI **13** — no embedder rebuild is required, and 0.18/0.19 hosts and clients still mix freely. The Windows virtual-display driver protocol is unchanged.
- **KWin display topology now runs in-process over `kde_output_management_v2` / `kde_output_device_v2`, not `kscreen-doctor`.** `kscreen-doctor` drives libkscreen, which waits on the kscreen KDED module over D-Bus; when that layer wedges it blocks in its own connect and never returns, so all five topology queries hit their 5 s budget and are killed — the physicals never disable and the virtual output never becomes the desktop (`also_disabled=[]`, ~26 s bring-up) even though the compositor's own Wayland is fully responsive. The host now resolves its output by stable device UUID (supersede-robust), takes primary, disables the physical/bootstrap outputs (capturing their modes to re-enable on teardown) and positions — all over bounded Wayland round-trips (management v19 bound + outputs enumerated in ~2.4 ms on KWin 6.6.4, ~3400× faster than the hang). Every wait is time-bounded, so a genuinely wedged compositor degrades to `handled = false` and the old `kscreen-doctor` path still runs.
- **>60 Hz custom modes install in-process too**, so the whole KWin path is kscreen-free on modern KWin. `set_custom_mode` builds a one-entry `kde_mode_list_v2` and applies it via `kde_output_configuration_v2.set_custom_modes` (since v18), waits for KWin to generate the mode (its CVT generator may align the width down — matched with the same height-exact / width-within-8 / refresh-within-1 Hz gate as before), then selects it, which drives the sacrificial-birth stream renegotiation. `set_custom_modes` *replaces* the custom list, so reconnects are idempotent — no more one-custom-mode-per-connect growth of the display's mode list — and a >60 Hz session no longer eats a 5 s kscreen resolve timeout on a box where the tool wedges. Pre-6.6 KWin without `set_custom_modes` falls back to `set_custom_refresh`.
- **The gamescope managed takeover is display-manager-flavor-aware.** Masking the box's `gamescope-session-plus` unit made plasmalogin's `Relogin=true` fail its Exec repeatedly and trip systemd's start limit within ~1 s — the DM dies, and the restore verb (unmask + user start) cannot bring a seatless gamescope back; only `reset-failed` + `restart` recovers. SDDM (Bazzite/SteamOS) keeps the proven mask + SIGKILL path. plasmalogin / unknown DMs are never masked: with privilege (root or a scoped operator polkit rule on the DM unit — documented) the host stops the DM for the stream and restores it with `reset-failed` + `restart` (recorded in the persisted takeover state so a host crash still restores); without privilege the managed takeover degrades to attach and mirrors the live Game Mode. Companion fixes from the same triage: an attach-only rebuild-probe guard on `ensure_box_gamescope_mode` (which also no longer re-modes a box that drives a physical display), a 100 s capture-loss rebuild budget for gamescope (the 40 s budget expired inside Steam's first ~45 s cold-start attempt), a once-per-capture-loss WARN when a `PUNKTFUNK_COMPOSITOR` pin no longer matches the live session, and a managed session that took nothing over is stopped on disconnect instead of orphaned.
- **In-stream "Switch to Desktop" is honored under the managed takeover.** Steam's session-select is a silent no-op while the DM is stopped (its config-write branches all require the DM running), so it merely relaunched Game Mode. The managed launch now baselines the `~/.config/steamos-session-select` sentinel's mtime; a capture loss with the sentinel advanced is read as the switch request and replayed with the DM up — restore the DM, run the distro's own `os-session-select` (its internal pkexec is authorized `allow_any` by the distro policy, so it works from the host's sessionless context), then stop the autologin game-mode unit so Relogin lands in the newly selected desktop. A 120 s post-honor grace keeps the rebuild loop from racing the booting desktop back into Game Mode (superseded early if the box's own game-mode unit reappears, so the desktop→game leg stays fast). The baseline is re-recorded on crash-restore so a pre-existing sentinel never reads as a fresh request.
- **Windows absolute input maps over the streamed output's desktop rect, not the whole virtual desktop.** Pen, touch and absolute mouse arrive normalized to the streamed display's frame, but `pointer_windows::to_screen` and sendinput's `MouseMoveAbs` mapped them across the entire virtual desktop — correct only when the virtual display is the sole active display (Exclusive topology). In Extend — a physical kept on beside the virtual output, or an Exclusive isolate degraded to the `0x57` keep-physicals fallback — the streamed output sits at a non-zero origin, so every sample landed shifted and mis-scaled, and the strictly-absolute pen (no closed-loop correction onto the target) exposed it first. New `pf-inject::stream_target` publishes the streamed output's CCD target id at capture bring-up (one central site — `capture_virtual_output` covers the native and GameStream planes); mapping sites resolve its current desktop rect through `pf-win-display`'s `source_desktop_rect` — the same resolver the cursor-readback poller uses, so inject and readback always agree — TTL-cached at 250 ms because a group-layout re-arrange moves a live output's origin mid-session. No target resolved falls back to the whole virtual desktop (still right for Exclusive topology). The same fix closes the identical latent absolute-mouse offset.
- **Windows display isolation anchors the kept sources at the desktop origin.** A committable CCD config must contain a primary pinned at exactly (0,0); deactivating the display that held the origin while the kept virtual stays at its EXTEND offset supplies an origin-less desktop, which Windows rejects wholesale (`0x57`) — on every shape, doomed-path-carried and keep-only escalation alike. The kept sources are now translated rigidly so the top-left-most lands on the origin (sets already covering (0,0) stay byte-identical, so plain re-commits don't churn). `restore_displays_ccd` also guarantees the desktop is never left all-dark: an unappliable snapshot (`0x64a`) or one that applies cleanly yet re-lights nothing falls back to the database EXTEND preset when no external physical is active afterwards (internal panels don't count — a closed lid isn't forced back on), and the final isolate-failure diagnostic now names the surviving targets rather than asserting a non-virtual display stayed active.