52 Commits

Author SHA1 Message Date
enricobuehler b57e414618 chore(release): bump workspace version to 0.8.0
apple / swift (push) Successful in 1m10s
audit / cargo-audit (push) Successful in 1m23s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m23s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m58s
release / apple (push) Successful in 9m58s
ci / bench (push) Successful in 4m55s
ci / rust (push) Successful in 8m57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m18s
apple / screenshots (push) Successful in 5m55s
windows-host / package (push) Successful in 7m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m19s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m16s
android-screenshots / screenshots (push) Successful in 2m29s
arch / build-publish (push) Successful in 5m11s
decky / build-publish (push) Successful in 19s
android / android (push) Successful in 4m36s
flatpak / build-publish (push) Successful in 5m7s
linux-client-screenshots / screenshots (push) Successful in 6m15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m14s
web-screenshots / screenshots (push) Successful in 2m40s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m45s
docker / deploy-docs (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 8s
deb / build-publish (push) Successful in 4m21s
The [workspace.package] version (inherited by every crate via version.workspace)
is the release being cut; refresh the 9 workspace entries in Cargo.lock to match
(CI builds --locked). Canary derives from the tag (scripts/ci/pf-version.sh), so
cutting v0.8.0 auto-advances canary to 0.9.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:39:52 +00:00
enricobuehler ec40a4062f build: pin the exact Rust toolchain (1.96.0) to stop rustfmt drift
apple / swift (push) Successful in 1m23s
arch / build-publish (push) Successful in 5m56s
android / android (push) Successful in 6m38s
apple / screenshots (push) Successful in 5m50s
ci / web (push) Successful in 1m10s
ci / docs-site (push) Successful in 1m27s
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 7s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 6s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 55s
windows-host / package (push) Successful in 14m52s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
rust-toolchain.toml pinned the floating "stable" channel, so the CI image baked whatever
stable existed at image-build time. When the image is rebuilt onto a newer stable,
rustfmt's rules shift and `cargo fmt --all --check` fails on files nobody touched — the
recurring format-drift that keeps red-lighting CI.

Pin channel = "1.96.0" (== today's stable: rustc ac68faa20, rustfmt 1.9.0-stable), the
exact build CI already runs, so this is a no-op now but locks formatting for good: local
dev, the Linux CI image, and the Windows runner all use rustup and honor this file, so
they converge on one rustfmt. Formatting now only changes in a deliberate bump-this-pin-
and-reformat commit. ci.yml cache comment updated to match.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:32:38 +00:00
enricobuehler 19c35de3d7 style(host): cargo fmt --all (rustfmt 1.9.0 drift)
The CI image's rustfmt reformats these files (multi-line assert!/tracing! macros,
match-arm and struct-variant wrapping) — pre-existing drift that the Format job caught.
Reformat to match. Pure formatting; no logic change. main.rs also gets a blank line
before a standalone comment so rustfmt stops mis-indenting it as a trailing-comment
continuation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:29:28 +00:00
enricobuehler aa012c6b45 feat(vdisplay/mutter): pin the client's refresh by default; drop PUNKTFUNK_MUTTER_VIRTUAL_REFRESH
The >60 Hz virtual-monitor path (RecordVirtual "modes" with the client's exact WxH@Hz)
was gated behind PUNKTFUNK_MUTTER_VIRTUAL_REFRESH, default OFF, after a high-refresh
virtual CRTC SIGSEGV'd gnome-shell on session teardown. That crash was since fixed by
stopping the screencast before any monitor reconfig, so the gate is dead weight — and a
silent footgun: every non-headless GNOME client was capped at Mutter's PipeWire-derived
60 Hz unless they knew the hidden flag.

Make it the default: the custom-mode path now runs whenever mode.refresh_hz > 60 (≤60 Hz
stays byte-identical to before — Mutter's 60 Hz default is already correct), and the
virtual_refresh_enabled() env read is removed. Docs updated (configuration.md env table,
vrr-plan.md reference).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:25:36 +00:00
enricobuehler 74c9e46faf fix(vdisplay/windows): Windows clippy — per-block SAFETY comments + then_some
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 4m38s
ci / rust (push) Failing after 30s
arch / build-publish (push) Successful in 5m25s
ci / web (push) Successful in 49s
ci / docs-site (push) Successful in 59s
apple / screenshots (push) Successful in 5m37s
windows-host / package (push) Successful in 7m54s
deb / build-publish (push) Successful in 3m1s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 5m34s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m16s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m6s
The branch's Windows-host code never ran through the fleet Windows clippy (kept off CI); the merge
to main exposed it. Fix the 4 -D warnings failures in cfg(windows) code:
- manager.rs: 3 unsafe blocks (isolate_displays_ccd / force_extend_topology / set_virtual_primary_ccd)
  had one "both arms" SAFETY comment on the `match` line — clippy::undocumented_unsafe_blocks wants it
  immediately before each block. Split into per-block SAFETY comments.
- win_display.rs: `.then(|| …)` on a POD u32 union read → `.then_some(…)` (eager is fine, discarded
  when false) for clippy::unnecessary_lazy_evaluations.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 19:04:06 +00:00
enricobuehler 95b3496bb5 merge: display-management (Stages 0-5 §6A + keep-alive hardening + gaming-rig)
apple / swift (push) Successful in 1m15s
windows-host / package (push) Failing after 3m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 57s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 1m2s
release / apple (push) Successful in 8m43s
android / android (push) Successful in 4m17s
ci / rust (push) Failing after 29s
ci / web (push) Successful in 49s
arch / build-publish (push) Successful in 5m33s
ci / docs-site (push) Successful in 59s
apple / screenshots (push) Successful in 5m42s
deb / build-publish (push) Successful in 3m7s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 28s
ci / bench (push) Successful in 4m41s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 44s
flatpak / build-publish (push) Successful in 4m30s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m21s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m25s
Merges display-mgmt-stage0 — the user-configurable virtual-display policy layer above the
per-compositor backends. On-glass validated (KWin .116 + Mutter .21; Windows compile-verified .173):

- Policy surface (keep_alive · topology · conflict · identity · layout · max) →
  display-settings.json, console-editable via /api/v1/display/{settings,state,release,layout} + a
  dedicated "Virtual displays" console section. All five axes enforced, not just stored.
- Lifecycle: pure state machine + Linux keep-alive pool (registry + DisplayLease ownership split),
  incl. keep_alive=forever/Pinned (freed via /display/release); topology extend/primary/exclusive
  (group-aware); per-client identity (KWin per-slot names → KDE scaling round-trips); mode_conflict
  admission (Windows default reject, single-capturer IDD); §6A multi-monitor (display groups +
  layout engine + console arrangement table — several clients as monitors of one desktop).
- Keep-alive reconnect hardened: same-client zombie preempt (never a 2nd display), deliberate-quit
  skip-linger (QUIT_CLOSE_CODE), tunable idle timeout (PUNKTFUNK_IDLE_TIMEOUT_MS).

Conflicts (packaging/{arch,debian}/README.md firewall docs): kept main's ufw/nft port commands +
the branch's --data-port documentation. build + clippy -D warnings + cargo test --workspace
(18 suites, 0 failed) green on the merged tree.
2026-07-05 18:22:17 +00:00
enricobuehler 334f36ce25 test(punktfunk1): serialize in-process-host tests (shared admission table)
The reconnect-preempt (b53710d, preempt_same_identity) reads the process-global admission table
and signals same-identity live sessions. The three in-process-host tests each bind a fixed loopback
port and share that ONE table, so running them concurrently let one test's connection preempt +
close another's live session — an intermittent `next_au: Closed` in c_abi_connection_roundtrip
(surfaced under full-workspace load; a lucky pass hid it at b53710d). Serialize them on a
poison-tolerant lock. Test-isolation only — in production a host is one process with unique client
certs, so same-identity preempt is correct. Full workspace `cargo test` now green (18 suites, 3× clean).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:09:59 +00:00
enricobuehler 88348153f3 feat(apple): wake-until-up overlay + host edit with MAC prefill
apple / swift (push) Successful in 1m10s
arch / build-publish (push) Successful in 5m17s
android / android (push) Successful in 7m30s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m11s
release / apple (push) Successful in 8m39s
ci / rust (push) Successful in 4m53s
deb / build-publish (push) Successful in 2m58s
decky / build-publish (push) Successful in 16s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 6s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
ci / bench (push) Successful in 4m50s
apple / screenshots (push) Successful in 5m44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m9s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m52s
- HostWaker + WakeOverlay: after sending the Wake-on-LAN packet, wait until the host
  is really back (resend + mDNS poll, timeout, cancel/retry) before connecting.
  macOS-only in practice — WoL stays gated off on iOS/tvOS pending the multicast
  entitlement.
- Add/Edit host sheet gains a Wake-on-LAN MAC field, prefilled from the stored MAC
  or the live mDNS advert; parseMacs validates aa:bb:cc:dd:ee:ff.
- Gamepad chrome/home and glass-style polish.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:05:17 +02:00
enricobuehler 4a87cef98c feat(android): console UI, wake-on-LAN wait-until-up, host edit + TV/tablet polish
Bring the Android client to parity with Apple's gamepad experience and finish
Wake-on-LAN.

- Console/gamepad home: host carousel, aurora chrome, mTLS game-library coverflow,
  and an input-aware legend that switches between gamepad face buttons and a
  TV-remote select-ring + arrows based on the last-used input.
- Wake-on-LAN: the fire-and-forget send is upgraded to wait-until-up
  (WakeController/WakeOverlay: resend + mDNS poll, 90s timeout, cancel/retry,
  fingerprint-matched so a host that cold-boots onto a new DHCP IP still connects),
  plus host edit (touch dialog + console form) with an auto-filled MAC.
- Android TV: brand banner (android:banner), density-aware console scaling, D-pad/
  remote nav (Up = Settings, Down or the pad Select button = host Options),
  emergency stream-exit chord, and 120Hz console refresh.
- Touch UI: settings split into subpages with a tablet NavigationRail, axis-aware
  tab animation (horizontal on phones, vertical on the tablet rail), animated
  settings navigation, and a licenses screen with a back button + the real
  workspace version (read from Cargo.toml).
- Vector Lock/controller icons (no emoji); bundled Geist font.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:05:17 +02:00
enricobuehler fc1e8a8a32 test(mgmt): display_settings_surface stays read-only (gaming-rig now accepted)
The old `..._and_forever_rejected` asserted a 400 for keep_alive=forever; now that it's accepted,
that PUT succeeded and WROTE gaming-rig into the process-global prefs, racing other tests. Rewrite
read-only: assert the surface (5 presets, effective, enforced axes) and read gaming-rig=forever off
the preset list — no write. Acceptance is covered on-glass (.116) + the pure policy tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 18:03:11 +00:00
enricobuehler 69f4c987f6 feat(tray): surface kept virtual displays in the tray tooltip
Stage 8 polish. `GET /api/v1/local/summary` (the tray's loopback-only unauthenticated status
source) gains `kept_displays` — the count of lingering/pinned virtual displays (held with no live
session), over the already-validated `registry::snapshot()`. The tray shows it in the idle tooltip
("idle · 1 display kept"), so a user knows a display — and, under exclusive topology, their physical
monitors — is being held (e.g. a gaming-rig `forever` pin). Release stays via the console: a
state-changing release can't be an unauthenticated endpoint, and the non-elevated Windows tray
can't read the SYSTEM-DACL'd mgmt token, so a tray release button isn't cleanly cross-platform.
`#[serde(default)]` on the tray side keeps it compatible with an older host. Tray tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:54:44 +00:00
enricobuehler 468a60c88a docs(display-management): Stage 8 consolidation — docs page, README, CLAUDE.md, host.env
Bring the user-facing + project docs in line with the shipped, on-glass-validated state (Stages
0-5 §6A + keep-alive hardening + gaming-rig) ahead of a merge decision:

- docs-site/virtual-displays.md: drop the now-false "stored but not yet enforced / following
  release" caveats — conflict handling, per-client identity + KDE scaling round-trip, and §6A
  multi-monitor layout are all live; gaming-rig/forever ships (freed via Release); document the
  reconnect-always-resumes + deliberate-quit-skips-linger behavior and the PUNKTFUNK_IDLE_TIMEOUT_MS
  knob. KDE persistent scaling →  today (validated); Windows primary → shipped; Sway exclusive
  stays "following release".
- README: a "displays you configure, not just create" differentiator bullet.
- CLAUDE.md: the display-management invariant now reflects Stages 0-5 shipped (all axes enforced,
  forever/Pinned, hardened reconnect) instead of "Stage 0 shipped".
- host.env.example: document PUNKTFUNK_IDLE_TIMEOUT_MS + that display policy lives in the console.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:50:32 +00:00
enricobuehler fad1e01408 docs(display-management): keep_alive=forever (gaming-rig) shipped
§5.1 + §7 matrix: Windows `Pinned` is shipped (ccbd7e8), the mgmt reject is gone, the console
preset is enabled, and `/display/release` frees a pinned monitor (the §8 escape hatch). On-glass
validated on Linux (.116 KWin); Windows compile-verified on .173, on-glass Pinned pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:44:13 +00:00
enricobuehler 04a397be84 fix(vdisplay/windows): drop an unused mut in isolate-retry (clippy -D warnings)
`let (mut paths, mut modes)` — `modes` is only read (`modes.as_slice()`), never mutated. A
pre-existing unused_mut (from 029d113) that the Linux CI never caught because win_display.rs is
#[cfg(windows)]; surfaced by a manual .173 build. Would fail the release-gated Windows clippy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:41:22 +00:00
enricobuehler ccbd7e8880 feat(vdisplay): ship keep_alive=forever (gaming-rig) — Windows MgrState::Pinned
Completes the last §6A-era preset. The Linux registry already resolved forever→Pinned (pure
lifecycle machine); the blockers were the Windows manager, the mgmt reject, and the console tag:

- Windows manager: new `MgrState::Pinned { mon }` — the last-released monitor under keep_alive=forever
  is kept indefinitely (like Lingering but the linger timer never fires). A reconnect preempts +
  recreates it (same as Lingering — a reused IddCx swap-chain is dead), snapshot reports "pinned",
  and `force_release` (POST /display/release, the §8 escape hatch) frees a pinned monitor. release()
  branches on the new `keep_alive_forever()`; all MgrState matches made exhaustive over Pinned.
- mgmt PUT /display/settings: stop rejecting keep_alive=forever (now honored on both platforms with a
  release path). OpenAPI regenerated.
- web: un-disable the gaming-rig preset (DISABLED_PRESETS now empty) — one-click applies.

Linux paths + web/tsc/openapi green; 47 vdisplay tests pass. The Windows manager.rs is #[cfg(windows)]
(not compilable on the Linux dev box) — build-verified + on-glass validation on .173 to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:26:43 +00:00
enricobuehler a0546b36b6 docs(display-management): record Stage 5 §6A on-glass validation + keep-alive hardening
Honesty pass after the 2026-07-05 on-glass session — the plan now reflects what was actually
validated, no more/no less:
- Stage 5 (§6A): HOST-SIDE → DONE + on-glass validated (KWin .116 + Mutter .21): group model,
  positions, identity keying, group-aware exclusive/extend coexistence, 2 concurrent Mutter
  RecordVirtual monitors. Remaining is hardware-gated residuals ONLY (per-group physical-restore
  EFFECT needs a monitor-attached box — headless reports also_disabled=[]; wlroots exclusive;
  Mutter APPLY_TEMPORARY revert).
- Stage 3: the KDE set-scaling ROUND-TRIP is now proven live (150%/125% → disconnect → reconnect
  → reapplied, kwinoutputconfig.json) — moved from Deferred to Validated. Closes the Stage-3 gate.
- §5.1: the explicit-quit bypass (b53710d) is now IMPLEMENTED on punktfunk/1 (QUIT_CLOSE_CODE →
  release(force_immediate)), plus the new same-client reconnect preempt and the tunable idle
  timeout — documented as built + validated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:12:23 +00:00
enricobuehler b71dc94bb2 test(probe): --seconds stream cap + flush the QUIC close before exit
Two probe test-infra fixes needed to validate the keep-alive hardening (b53710d) on glass:
- `--seconds N` caps the receive loop (was a hardcoded 120s), so a probe against a live `serve`
  host ends its session promptly and reaches the graceful `conn.close`.
- After `conn.close`, wait for the endpoint to flush the CONNECTION_CLOSE frame (bounded 2s)
  before exiting — otherwise the process drops the endpoint before quinn sends the close, and the
  host waits out the idle timeout instead of seeing the close CODE (which the `--quit` deliberate-
  quit path and normal code-0 close both depend on).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 17:00:09 +00:00
enricobuehler c2bc72a8e9 fix(packaging): correct CachyOS firewall to ufw + ship ufw openers + web-console opener
apple / swift (push) Successful in 1m11s
android / android (push) Successful in 4m1s
apple / screenshots (push) Successful in 4m29s
arch / build-publish (push) Successful in 5m52s
ci / web (push) Successful in 1m16s
ci / docs-site (push) Successful in 1m11s
ci / rust (push) Successful in 4m54s
deb / build-publish (push) Successful in 3m0s
decky / build-publish (push) Successful in 24s
ci / bench (push) Successful in 4m44s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m50s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m30s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m5s
docker / deploy-docs (push) Successful in 22s
CachyOS ships ufw enabled by default (firewalld is not installed) — verified live
on the .21 box — but the docs and shipped firewall openers claimed "CachyOS enables
firewalld by default". Correct that everywhere and ship a ufw application profile
(the one-liner analogue of the firewalld service files):

- packaging/linux/punktfunk.ufw (new): [punktfunk-native], [punktfunk-gamestream],
  [punktfunk-web] profiles, installed to /etc/ufw/applications.d/punktfunk by the
  Arch (CachyOS) and .deb host packages. `sudo ufw allow punktfunk-native`.
- packaging/linux/punktfunk-web.xml (new): firewalld service for the optional web
  console (TCP 47992), installed by the host package on arch/deb/rpm. Neither the
  native nor gamestream opener covered 47992, so a firewalld/ufw host that enabled
  punktfunk-web could not reach the console over the LAN.
- Fix the "CachyOS enables firewalld" claim in arch.md, arch/README.md,
  debian/README.md, both firewalld service .xml comments, and the pacman scriptlet;
  firewalld now attributed to the spins that use it (EndeavourOS, Fedora/RHEL).
- Docs present both one-liners (ufw + firewalld) whichever firewall you run, plus a
  console-opener step; postinst/scriptlet hints detect ufw as well as firewalld.

The native data plane stays hole-punched (ephemeral UDP, no fixed port) — its
openers correctly open only 9777/udp + mDNS; the stale "open a UDP range" note is
replaced with the accurate outbound-UDP explanation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:52:35 +00:00
enricobuehler b53710da1a feat(vdisplay): harden keep-alive reconnect — same-client preempt, quit-skips-linger, configurable idle
On-glass testing (Test 2, KWin .116) surfaced that a reconnect within the QUIC idle-timeout
window (~8s) lands on a fresh SECOND display instead of reusing the kept one: the old session
was still Active (not yet Lingering), so the registry's keep-alive reuse (which only matches
Lingering) skipped it and the old session kept streaming to nobody. Three fixes:

#3 Same-client reconnect preempt (the real fix): admission::preempt_same_identity() lists a
   reconnecting client's OWN still-live session(s) (same cert fingerprint); serve_session signals
   their stop + waits the release grace BEFORE acquiring, so the zombie tears down → its display
   lingers → the reconnect REUSES it instead of making a second. Implements the "preempts
   downstream" the admission docs already promised. Independent of the mode_conflict policy; the
   pure core (same_identity_stops) is unit-tested.

#2 Deliberate quit skips linger: a client that deliberately disconnects closes the QUIC connection
   with QUIT_CLOSE_CODE (0x51, shared in core::quic); the host reads the ApplicationClosed reason
   and tears the display down immediately (registry release() gained force_immediate →
   Linger::Immediate; multi-session-safe via the pure lifecycle machine), while a bare disconnect
   still lingers for reconnect. Threaded via a session quit flag → the DisplayLease.
   NativeClient::disconnect_quit() + punktfunk-probe --quit drive it; GameStream (Quit App /
   h_cancel) is a documented follow-up.

#1 Configurable disconnect-detection latency: the QUIC control-connection idle timeout
   (stream_transport, 8s default) is host-tunable via --idle-timeout-ms / PUNKTFUNK_IDLE_TIMEOUT_MS,
   clamped >=1s with a keep-alive that scales to it so a live session never false-closes. Default
   unchanged (8s stays load-bearing for the Windows IDD-push reconnect flow).

Workspace check + 63 core / 215 host / 47 vdisplay tests green; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:41:06 +00:00
enricobuehler c1acfe8b85 fix(web): preset cards use the design-system animated Card (motion + material)
The preset options were raw <button>s — flat, no motion/material — unlike the rest of
the console. They now render as the `interactive` AnimatedCard (motion hover + specular
material, consistent with every other card), keyboard-accessible (role=button + Enter/
Space), with a 2px primary ring for the active one and a proper disabled state for
gaming-rig.

web tsc + vite build + biome-lint green; deployed on .21.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:18:24 +00:00
enricobuehler 2e43fcc27c fix(web): unify display-field spacing (shared Field) + clearer layout help
- Every option in the custom form now renders through one `Field` wrapper (label →
  control → help at a consistent `space-y-3`), so the label→input gap is roomier and
  identical across keep-alive, the button groups, and max-displays — the first field no
  longer spaces differently from the rest.
- Reworded the multi-monitor layout help: it now says Auto is side-by-side and Manual
  gives a per-display X/Y editor "in the Live displays section below once two or more are
  streaming" — instead of pointing at an "arrangement table" that isn't visible until
  clients connect.

web tsc + vite build + biome-lint green; deployed on .21.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:07:45 +00:00
enricobuehler 2aa7ac8c7e fix(web): explicit keep-alive Off/Keep toggle + roomier custom display form
Two UX fixes on the Virtual displays Configuration card:

- Keep-alive is no longer implicitly "on" by typing in the seconds field. It's an
  explicit two-button toggle — **Off** (tear down at disconnect) vs. **Keep for** [N]
  seconds — and the seconds input only appears when "Keep for" is selected. The
  duration is remembered across toggles, and the help text explains both modes.
- Opened up the cramped custom form: the fields container is `space-y-6` with more
  padding (`p-5`, rounded-lg), each option group is `space-y-2.5`, and the Save button
  sits below a divider — so it reads as sections with room instead of a pressed stack.

web tsc + vite build + biome-lint green; deployed on .21.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 15:00:57 +00:00
enricobuehler 6b4f9f86ed feat(web): move Virtual displays to its own nav section; roomier preset grid
The Host page was crowded (identity, codecs, ports, GPU, displays, compositors) and the
virtual-display config surface is large enough to warrant its own home.

- New **Virtual displays** nav section: `/displays` route + `sections/Displays` (moved
  DisplayCard out of `sections/Host`), a `MonitorPlay` sidebar entry after Host, and
  `nav_displays` i18n. Removed the displays card from the Host page/view.
- On its own page the card splits into two: **Configuration** (presets + custom axes) and
  **Live displays** (the live list + arrangement table) — room to breathe.
- Presets now render in a max-2-column grid (`sm:grid-cols-2`) with larger padding, a bigger
  section heading + preset titles (text-base semibold), roomier spacing, and bottom-aligned
  "what it sets" badges so the cards line up.

web tsc + vite build + biome-lint green; deployed + verified on the Mutter box (.21).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:54:24 +00:00
enricobuehler 8986667b78 feat(web): full virtual-display config surface — one-click presets + every axis editable
The Virtual displays card previously only exposed keep_alive/topology/max_displays as
editable custom fields; conflict/identity/layout (enforced since Stages 3-5) had no
controls, and the presets weren't surfaced as one-click options. Rework the card so the
whole policy is configurable WITHOUT any client connected:

- Presets front-and-center: each of the five (default/shared-desktop/hotdesk/workstation/
  gaming-rig) is a one-click row showing its story AND what it sets (keep-alive · topology ·
  conflict · identity badges), highlighting the active one. A click applies it immediately.
  gaming-rig stays disabled + "coming soon" (keep_alive: forever isn't cross-platform yet).
- Custom mode reveals EVERY axis editably — keep-alive, topology, conflict, identity, layout,
  max-displays — seeded from the current effective behavior, with a Save button. A reusable
  `Choice` button-group + a tolerant `tr()` label lookup keep it tidy.
- The live-display list + multi-monitor arrangement table stay below (they need a live
  session); the settings above work standalone.
- en+de i18n for the new controls; refreshed the effective-preview row to show all axes.

web tsc + vite build + biome-lint green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 14:38:40 +00:00
enricobuehler 62e0367f4b feat(punktfunk1): configurable data-plane UDP port (--data-port)
The native data plane used a random ephemeral UDP port (hole-punched), which a
strict firewall can't pre-open — so remote clients behind one couldn't connect.
Add an optional fixed data port:

- `Punktfunk1Options`/`NativeServe` gain `data_port`; `bind_data_socket` binds the
  fixed port (→ direct, no hole-punch) or falls back to a random port + hole-punch
  when unset or the fixed port is busy (a concurrent session already holds it).
- `UdpTransport::from_socket`/`from_socket_punch` adopt an already-bound socket, so
  the host keeps the SAME data socket from handshake through streaming — no
  drop-then-rebind window in which a concurrent session could steal a fixed port.
- `main.rs` wires the CLI flag through to `NativeServe`.
- Firewall docs updated (troubleshooting.md + apt/pacman/bazzite READMEs): control
  plane is the fixed UDP 9777; the data plane is a separate random port that usually
  needs no rule, with the fixed-port option for strict firewalls.

Unit-tested: default random+hole-punch, and fixed-port-then-fallback-when-busy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:53:54 +00:00
enricobuehler 677a4f4cf5 perf(gamestream): move FEC packetization off the encode loop (3-stage pipeline)
FEC/Reed-Solomon packetization ran inline on the encode loop (~3 ms/frame at 4K),
serializing behind encode and capping the GameStream frame rate below what the
encoder alone can sustain. Split it into a 3-stage pipeline, each stage on its own
thread joined by a depth-2 bounded queue:

  encode loop → [raw AUs] → packetizer (FEC/RS) → [wire batch] → paced sender

- `spawn_packetizer`: turns each `RawFrame`'s access units into wire datagrams via
  the stateful VideoPacketizer, off the encode loop. Above-normal priority (on the
  per-frame critical path). Tallies goodput (bytes to the wire) for the stats window.
- Backpressure chains up: a slow sender blocks the packetizer, which fills the
  encode→packetizer queue, which makes the encode loop drop the NEWEST frame — encode
  itself never waits.
- A dropped frame now consumes no client-visible frameIndex (packetization is
  downstream), so the host re-anchors the reference chain: a drop arms a keyframe on
  the next iteration (`recover_after_drop`), routed through the same coalesce gate as
  client IDR requests so a burst of drops (congestion) can't become an IDR storm.
- Perf/stats relabeled: `pkt` = AU drain, `send` = enqueue to the pipeline (both
  should be near-zero now; nonzero = encode being stalled by pipeline backpressure).
  Goodput read from the packetizer's atomic at the 1 s stats boundary.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:53:43 +00:00
enricobuehler fa45608628 docs(display-management): Stage 5 host + web build complete — only on-glass validation + residuals left
Mark the web arrangement table done and narrow "remaining Stage 5" to validation
(2 clients on a GPU box, not the dev VM) plus the two documented residuals (wlroots
exclusive, Mutter APPLY_TEMPORARY revert). No further host/web build work in Stage 5.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:33:05 +00:00
enricobuehler a7ff1cf312 feat(web): display arrangement table — the Stage 5 console x/y editor
Completes Stage 5's web piece (design/display-management.md §6.2): a `DisplayArrangement`
editor in the Virtual displays card. For a ≥2-display group, it renders an x/y table over
the live displays that carry a stable identity slot (the manual-layout key), seeded from
the current computed positions; Save writes `PUT /display/layout` (via the generated
`useSetDisplayLayout`), which switches the host to a manual layout applied from the next
connect. Shared/anonymous displays (no identity slot) are omitted (they can't be pinned).

Also refreshes the now-stale `display_pending_note` copy (conflict/identity/layout ARE
enforced as of Stages 3-5) in en + de.

web tsc + vite build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:32:02 +00:00
enricobuehler 87435e6547 feat(vdisplay): complete Stage 5 §6A group semantics — per-group restore, Mutter group-aware, gamescope groups
Host-side completion of Stage 5 (§6A many-clients-as-monitors), all unit-tested;
two-session on-glass validation still pending (no GPU on the dev VM):

- Per-group topology restore (§6.1): the KWin `exclusive` restore no longer rides
  the per-session StopGuard (which re-enabled the physical the moment the FIRST of
  several exclusive sessions dropped, under a live sibling). KWin hands its restore
  to the registry as a closure (new trait `take_topology_restore`); the registry
  keeps it in the display group (`Entry.topology_restore`) and, on teardown, floats
  it to a surviving same-group sibling (`hand_off_restore`) or runs it when the group
  empties — outside the lock, before the last output's keepalive drops, so the
  compositor never sees zero outputs. All three teardown paths (lease drop / linger
  expiry / mgmt release) honor it. Single-display path byte-for-byte unchanged.
  Unit-tested: float / run-on-last / non-carrier-first / never-cross-backend.

- Mutter group-aware (new trait `set_first_in_group`): the registry tells each
  backend whether it's the first display of its group; a non-first Mutter session
  EXTENDS into the already-exclusive desktop instead of re-applying a sole-monitor
  ApplyMonitorsConfig that would disable the first session's virtual. (Mutter
  connectors are un-nameable, so it can't build a keep-all-virtuals config; skipping
  is the safe equivalent.) Single-session unchanged. Residual APPLY_TEMPORARY revert
  documented.

- gamescope groups (§6.1): `registry::group_key` makes each gamescope spawn its own
  group (independent nested session, no shared desktop) — never auto-rowed against or
  restore-/topology-grouped with another gamescope. Applied in both the /display/state
  assembly and the acquire-time position computation. Unit-tested.

Remaining Stage 5: the web console arrangement table, on-glass validation, and the
documented residuals (wlroots exclusive, Mutter APPLY_TEMPORARY). design doc updated.

cargo build/test (214)/clippy --all-targets/fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 13:26:25 +00:00
enricobuehler e0f15822ae feat(vdisplay): Stage 5 layout foundation — arrangement engine + /display/layout + group placement
§6A layout, riding the Stages 1-3 registry with no protocol change:

- vdisplay/layout.rs: pure arrangement engine — auto-row (left-to-right in
  acquire order, top-aligned) + manual (per-identity-slot offsets, auto-row
  fallback for unpinned members). Unit-tested.
- Registry group model (Linux): group = backend (one desktop per compositor
  session). /display/state groups entries, orders by acquire (gen), and computes
  each member's position via the engine (pure `assemble_displays`, unit-tested).
  DisplayInfo carries group/display_index/position/identity_slot/topology. The
  backend reports its resolved slot via the new VirtualDisplay::last_identity_slot
  (KWin only), so the arrangement + state key on per-client identity.
- Registry-driven position apply: new VirtualDisplay::apply_position(x,y) (default
  no-op; KWin drives kscreen-doctor). Right after create the registry computes the
  new display's position over its whole group (pure `position_for_new`, unit-tested)
  and applies it — one seam for BOTH deterministic auto-row AND manual placement.
  Guarded: the origin (0,0) is skipped, so a single-display / first-of-group session
  (and every non-KWin backend) issues no positioning — the historical single-display
  path is unchanged. On-glass-validation-pending.
- PUT /api/v1/display/layout: persists the console's manual arrangement via the pure
  EffectivePolicy::with_manual_layout transform (locks current effective behavior
  into explicit Custom fields + sets a manual layout, so arranging is orthogonal to
  the other axes). OpenAPI regenerated.
- /display/settings `enforced` now lists all five axes (keep_alive, topology,
  mode_conflict [Stage 4], identity [Stage 3], layout [Stage 5]) — was stale at
  keep_alive+topology; the console reads it to know which controls are live.

Still Stage-5 TODO (design/display-management.md §11): Mutter/wlroots group-aware
analogues, per-group topology restore, the web arrangement table, gamescope decline.

cargo build/test/clippy/fmt green; OpenAPI in sync.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 12:59:04 +00:00
enricobuehler a5dc3134de docs(display-management): handoff — mark Stages 0-4 done, Stage 5 started
Update the design doc for handoff: top-of-doc status, a Status/handoff block in §11
(per-stage state, validation boxes, key decisions), and per-stage [DONE]/[STARTED]
markers. Records the decisions that diverged from the plan as written — the Windows
admission default is reject (single-capturer IDD-push), reject is typed (QUIC 0x42),
Stage 5's group-aware exclusive fixes a Stage-3 latent bug — and what's left in
Stage 5 (Mutter/wlroots analogues, layout, /display/layout, per-group restore).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:50:50 +00:00
enricobuehler eddcd91f48 feat(vdisplay/kwin): group-aware exclusive — never disable a sibling output (Stage 5 §6.1)
The critical latent bug Stage 3 introduced: per-slot output names mean a 2nd
exclusive session's other_enabled_outputs() (which disabled 'everything not named
Virtual-punktfunk') would black out the 1st session's Virtual-punktfunk-<id>
output. Fix: recognise the whole managed group by the shared Virtual-punktfunk
prefix — exclusive now disables only NON-managed outputs (bootstrap/physical),
never a group sibling. Plus first-slot-wins for the group primary
(a_managed_output_is_primary): a later session joins as a secondary monitor of the
shared desktop instead of stealing the shell off the first. Unit-tested.

Start of Stage 5 (§6A many-clients-one-desktop). Remaining: Mutter/wlroots
group-aware analogues, layout (auto-row/manual + /display/layout + console),
per-group topology restore, gamescope groups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:44:41 +00:00
enricobuehler 23446fa177 fix(vdisplay): Windows admission default is reject, not join (single-capturer limit)
Two concurrent Windows sessions both drive the same pf-vdisplay monitor's
single-capturer IDD-push channel (newest-delivery-wins), which freezes the live
client and can wedge the driver (observed live: a concurrent-session test wedged
.173 → Moonlight 'no video'; needed a reboot). True multi-session capture is §6.6/
Stage 7. So on Windows 'separate' (incl. the unconfigured default) now resolves to
REJECT — a 2nd client gets a clean 503 and the live session is protected — instead
of join (which would freeze it). join/steal stay explicit opt-ins; Linux keeps
separate (real multi-view). Centralized as admission::effective_conflict(), shared
by the native handshake + GameStream h_launch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 11:32:52 +00:00
enricobuehler 980939ed6b refactor(gamestream): extract + unit-test gamestream_admission (Stage 4)
Pull the GameStream mode-conflict decision out of h_launch into a pure
gamestream_admission(live, req_fp, policy) -> GsDecision so the 503/join/take-over
logic is unit-tested (no live session / same-client → Serve; different client →
Reject/Join/Serve per policy; anonymous requester treated as different) — the
GameStream path can't be driven without a Moonlight client, so this covers the logic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:43:08 +00:00
enricobuehler cfad0cf7ee feat(vdisplay): finish Stage 4 — typed reject, Windows join-default, GameStream 503
Completes the mode-conflict admission surface deferred from the initial Stage 4:

- REJECT now delivers the reason to the client: punktfunk/1 closes the QUIC
  connection with a distinct BUSY code (0x42) + the 'host busy: streaming WxH@Hz to
  <client>' string, which the client reads from ApplicationClosed (validated on
  loopback: the probe logs 'closed by peer: host busy … (code 66)').
- Windows default: separate (incl. the unconfigured default) resolves to JOIN — the
  Windows native host admits a second client at the live mode instead of the old
  silent last-wins reconfigure of the shared monitor (release-note behavior fix; the
  reconfigure is now opt-in as steal). separate stays multi-view on Linux.
- GameStream 503: h_launch tracks the session owner fp (LaunchSession.owner_fp, kept
  [u8;32] for Copy) and applies the policy when a DIFFERENT paired client launches —
  reject → 503 (Moonlight 'host busy'), join → serve the live mode, steal/separate →
  take over. Same-client re-launch is never a conflict.

Native reject-reason loopback-validated; Windows join-default pending .173 rebuild;
GameStream 503 pending a Moonlight client (can't drive /launch autonomously).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:34:49 +00:00
enricobuehler 42b1158ea7 feat(vdisplay): mode-conflict admission — separate/join/steal/reject (Stage 4)
The mode_conflict policy is now enforced at ADMISSION, before the punktfunk/1
Welcome, when a DIFFERENT client connects while another client's session is live:
- separate (default, unconfigured → no change): each client its own display.
- join: admit at the live display's mode (honest-downgrade — the Welcome carries it).
- steal: signal the victim session(s)' stop flags, wait the release grace, serve.
- reject: refuse the handshake with a busy reason (live mode + client label).

New vdisplay/admission.rs: the pure decide() (unit-tested — same-client never
conflicts, anonymous clients each distinct, join targets the oldest session) + a
live-session registry (identity + mode + stop flag) sessions register in once up.
Wired into punktfunk1 serve_session: admit() before validate_dimensions, register
after the data plane binds. A same-client reconnect never conflicts.

Validated on loopback (two probes, distinct identities, differing modes) across all
four policies: separate→own mode, join→live mode, steal→victim interrupted,
reject→handshake refused.

Remaining Stage-4 surface (deferred): GameStream 503 path, Windows-specific
defaults (separate→join map, silent-reconfigure→steal), reject reason delivered to
the client as a typed message (currently host-side log + connection close).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 10:21:28 +00:00
enricobuehler 029d1134a9 harden(vdisplay/windows): verify+retry exclusive isolation; pack primary layout
Exclusive (topology=exclusive) was fire-and-forget — a field-reported bug had a
physical monitor STAY ACTIVE. isolate_displays_ccd now re-queries after each apply
and RETRIES (up to 4x) until count_other_active()==0, never trusting rc alone;
logs SOLE-active on success, an error if a display survives all attempts. Secure
desktop correctness depends on the lock screen not landing on a stray panel.

Primary: drop the temporary per-path diagnostic; pack the kept displays left-to-
right from the virtual's right edge instead of blindly shifting each by virt_width
(which left a dead gap when extend already placed them right).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:55:13 +00:00
enricobuehler e35b6991e2 fix(vdisplay/windows): topology=primary force-extends to reactivate the physical
Root cause: on a headless box the IDD auto-activates as the SOLE display, so
QueryDisplayConfig sees only the virtual — the physical is already deactivated
before set_virtual_primary_ccd runs (no physical to keep). Force EXTEND first to
reactivate every connected display alongside the virtual, then reposition to make
the virtual primary, keeping the physical active.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:41:20 +00:00
enricobuehler 913f6ce659 diag(vdisplay/windows): log active paths in set_virtual_primary_ccd
Temporary diagnostic — the physical monitor goes black in topology=primary
despite rc=0; the SSH/session-0 view can't see the real interactive-session
topology, so log the active paths the host actually operates on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:36:53 +00:00
enricobuehler d23bd9b0cf fix(vdisplay/windows): DISPLAYCONFIG_PATH_SOURCE_INFO union field access
modeInfoIdx lives in the Anonymous union (windows-rs), not directly on
sourceInfo — set_virtual_primary_ccd now reads .Anonymous.modeInfoIdx.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:27:07 +00:00
enricobuehler eda7cac78e feat(vdisplay/windows): topology=primary — keep physicals active, virtual primary
Implements the deferred Windows primary-only CCD (Stage 2). set_virtual_primary_ccd
repositions the virtual output's source to (0,0) = primary and shifts the physical
display(s) to its right, ALL kept active — one atomic CCD SetDisplayConfig (not GDI
CDS_SET_PRIMARY, which storms MODE_CHANGE_IN_PROGRESS with another display live).
The manager's should_isolate() becomes topology_action() (3-way): extend (skip),
primary (set_virtual_primary_ccd), exclusive (isolate_displays_ccd). Restore-on-teardown
covers both. Validates the user's two scenarios on a physical-monitor .173.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 09:17:41 +00:00
enricobuehler d73951414c feat(vdisplay): KWin per-slot output naming for persistent scaling (Stage 3)
The KWin backend names its output Virtual-punktfunk-<id> from the client's
stable identity slot, so KWin persists per-output config (scale/mode) by name in
kwinoutputconfig.json and reapplies that client's scaling on reconnect — the KDE
scaling ask. Also fixes the latent clash where two concurrent sessions both used
Virtual-punktfunk (topology name-matching now uses the per-slot name).

- identity::global() + resolve_slot(fp, mode, default) — the shared persisted map
  (Windows manager dropped its own field; both use the global — never same-process).
  Default identity is per-platform: PerClient on Windows, Shared on Linux, so
  unconfigured hosts keep today's behavior (Linux = single 'punktfunk' name).
- KwinDisplay carries the client fp (set_client_identity), computes the per-slot
  name, threads it through the stream_virtual_output name + the topology helpers
  (set_custom_refresh / apply_virtual_primary[_only] / other_enabled_outputs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 08:54:39 +00:00
enricobuehler b150d79626 feat(vdisplay): platform-neutral identity map + per-client-mode (Stage 3)
Generalize the Windows-only per-client stable-id map into vdisplay/identity.rs:
- DisplayIdentityMap keyed on a composable string (identity_key: fingerprint,
  or fingerprint+resolution under per-client-mode); LRU at 15, persisted to
  display-identity.json (migrated from the legacy pf-vdisplay-identity.json).
- Windows manager wired to it, picking the key from the identity policy.
- Foundation for KWin per-slot output naming (persistent KDE scaling) — the
  KWin wiring is the next Stage-3 step (needs a KWin box).
- Unit-tested (stable, per-client-mode split, LRU, key composition).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 08:40:18 +00:00
enricobuehler cb7ddc0411 feat(vdisplay): topology decoupling — distinct primary level (Stage 2)
The three topology levels become distinct behaviors (Stage 0 only did
extend-vs-exclusive, faking primary):
- vdisplay::effective_topology() -> the concrete level (console policy > legacy
  *_VIRTUAL_PRIMARY env > Auto default). Backends read it directly at create
  time; apply_session_env no longer writes the boolean env (one fewer connect-
  path env mutation).
- Mutter: extend (no config), primary (virtual primary + physicals kept as
  secondaries — build_primary_keeping_physicals), exclusive (sole, physicals
  disabled). KWin: extend (no-op), primary (kscreen primary only), exclusive
  (primary + disable others).
- Windows should_isolate treats primary as isolate (the primary-only CCD variant
  is a follow-up); wlroots exclusive + the physical-keep effect need a
  display-attached box (headless lab boxes can't observe primary vs exclusive).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:18:46 +00:00
enricobuehler 60816709c4 fix(vdisplay): call life.acquire() outside debug_assert (release no-op)
The pooled entry's lifecycle transition was inside debug_assert_eq!, whose
arguments don't evaluate in release builds — so acquire() never ran, the entry
stayed Idle, and release saw Noop → immediate teardown (no keep-alive). Caught
on-glass on the CachyOS box.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:45:36 +00:00
enricobuehler 783c52dfad feat(vdisplay): Linux keep-alive pool — registry-owned display lifecycle (Stage 1b)
The ownership split (design/display-management.md §3): the registry owns the
per-session virtual-display lifecycle on Linux, so a display can outlive its
session (keep-alive) and be reused on reconnect.

- registry.rs: a Linux pool driven by the pure lifecycle machine. acquire()
  reuses a kept (lingering/pinned) display of the same backend+mode, else
  creates one and keeps the backend's keepalive so the compositor output (and
  its PipeWire node_id) survives the session. The session's capturer holds a
  gen-stamped DisplayLease instead of the real keepalive; its drop drives
  linger/teardown. Enabling fact: KWin/Mutter/gamescope put their node on the
  DEFAULT PipeWire daemon (remote_fd=None) — reconnect re-attaches by node_id,
  no fd re-open. wlroots (remote_fd=Some, xdpw portal) passes through unchanged
  (teardown-on-drop) pending the fresh-portal-capture re-attach.
- Default (unconfigured) linger = Immediate → today's teardown-on-disconnect,
  so no behavior change without a keep-alive policy; concurrent sessions still
  each create their own output (reuse only matches LINGERING entries).
- Wired build_pipeline (punktfunk1) + gamestream through registry::acquire;
  capture_virtual_output signature unchanged. Windows delegates to vd.create
  (the manager already leases) — unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 23:37:21 +00:00
enricobuehler e27718b406 packaging: ship firewalld services on rpm + deb too, share from packaging/linux
apple / swift (push) Successful in 1m10s
apple / screenshots (push) Successful in 5m45s
android / android (push) Successful in 4m2s
arch / build-publish (push) Successful in 5m37s
ci / web (push) Successful in 1m4s
ci / docs-site (push) Successful in 1m9s
ci / rust (push) Successful in 4m39s
deb / build-publish (push) Successful in 2m56s
decky / build-publish (push) Successful in 14s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 3s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
ci / bench (push) Successful in 4m41s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m8s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m54s
Mirror the Arch firewalld service definitions into the RPM spec and the Debian
host package so every Linux packager installs them, and move the two XML files
to the shared packaging/linux/ home (alongside the .desktop files both the
PKGBUILD and deb scripts already source there) so there's one source of truth
instead of three drifting copies.

- rpm: install punktfunk-{gamestream,native}.xml to /usr/lib/firewalld/services/,
  list them in %files host, and print the firewalld enable command in %post
  (gated on firewall-cmd). Fedora/RHEL run firewalld by default, so this is where
  it matters most; Bazzite inherits it via the sysext built from the package /usr.
- deb: install both XMLs in build-deb.sh and add the same firewalld-gated hint to
  the postinst. Debian/Ubuntu ship no active firewall, so it's a no-op unless the
  admin runs firewalld.
- PKGBUILD + arch README updated to the packaging/linux/ path.
- Firewall docs (bazzite README now leads with --add-service; debian README gains
  a firewalld block) point at the shipped services; XML comments made
  distro-neutral. Never auto-enabled — packages don't touch the admin's firewall.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:37:45 +00:00
enricobuehler 6bc893e394 docs(arch): fish-safe repo setup, firewalld services, fix client label
apple / screenshots (push) Successful in 5m25s
android / android (push) Has been cancelled
apple / swift (push) Successful in 1m13s
ci / rust (push) Successful in 5m26s
arch / build-publish (push) Successful in 6m6s
ci / web (push) Successful in 50s
ci / docs-site (push) Successful in 59s
deb / build-publish (push) Successful in 2m58s
decky / build-publish (push) Successful in 25s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 16s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
ci / bench (push) Successful in 4m45s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 44s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m5s
docker / deploy-docs (push) Successful in 20s
The pacman-repo setup step used a bash heredoc (`<<'EOF'`), which fish — the
default shell on CachyOS — cannot parse ("expected a string, but found a
redirection"). Replace it with a cross-shell `printf | sudo tee -a` form in both
the Arch guide and packaging/arch/README.md; `$repo`/`$arch` stay literal for
pacman and the output is byte-identical to the old heredoc.

Firewall: stock Arch ships none (ports already open), but CachyOS enables
firewalld by default and an Arch package must never touch the running firewall.
Ship firewalld service definitions the host package installs to
/usr/lib/firewalld/services/ (punktfunk-gamestream, punktfunk-native), not
auto-enabled; the install scriptlet prints the enable command only when
firewall-cmd is present. Document it in the Arch guide (new section) and README.
The mgmt API (loopback) and web console ports are deliberately not opened.

Also fix the "GTK4 couch/Deck client" mislabel — it's the native
GTK4/libadwaita Linux client (desktop/laptop/Deck are targets; the
controller-optimized launcher is one view, not its identity) — across the Arch
PKGBUILD/README, Arch guide, and the Debian README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 22:31:53 +00:00
enricobuehler f0d015fc45 fix(apple/macos): drop the rejected audioanalyticsd sandbox exception
apple / swift (push) Successful in 1m18s
arch / build-publish (push) Successful in 5m4s
release / apple (push) Successful in 8m16s
ci / rust (push) Successful in 6m2s
android / android (push) Successful in 11m29s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 1m0s
apple / screenshots (push) Successful in 5m32s
deb / build-publish (push) Successful in 3m1s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
ci / bench (push) Successful in 4m46s
App Review declined 0.4.2 (3384) under guideline 2.4.5(i): the temporary
com.apple.security.temporary-exception.mach-lookup.global-name =
com.apple.audioanalyticsd exception "is not appropriate and will not be
granted." It had been added on the theory that CoreHaptics controller
rumble (RumbleRenderer / MenuHaptics) hard-crashes under the App Sandbox
without it, since the framework reaches the audio-analytics daemon over
Mach and the sandbox denies that global-name lookup.

Tested the theory directly on macOS with a real Xbox pad: a
CHHapticEngine start + full-intensity rumble in a genuinely enforced
sandbox (NSHomeDirectory redirected into the app container) with no
exception on the codesigned binary runs fine — no crash — even with a
live AVAudioEngine stream running concurrently. CoreHaptics tolerates
the denied lookup; the exception was never load-bearing.

So just remove it: CoreHaptics session rumble and menu haptics keep
working on macOS unchanged (no source change needed). DualSense stays on
its raw-HID path — a genuine Sony-motor gap — which needs no exception
either.

Resubmit requires a new build number and clearing the App Store Connect
App Sandbox entitlement-usage justification for this exception.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 00:21:25 +02:00
enricobuehler 2dd17dda80 test(mgmt): display state/release endpoint smoke test
Covers the idle path (empty /display/state + released:0 /display/release) on a
unit-test host, exercising the wiring + auth without touching any global owner.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 21:27:52 +00:00
enricobuehler 87f0ce7997 feat(vdisplay): lifecycle state machine + display state/release API (Stage 1)
Stage 1 of design/display-management.md — the lifecycle core + the display
management surface:

- vdisplay/lifecycle.rs: pure per-slot state machine (Idle/Active{refs}/
  Lingering{until}/Pinned) with acquire/release/expiry/force-release
  transitions. No I/O, no OS types — the platform-neutral distillation of the
  Windows manager's model. Unit + a 200k-iteration seeded property walk
  (no leaks / double-frees / refcount underflow across arbitrary interleavings).
- vdisplay/registry.rs: neutral snapshot/release facade over the per-OS
  lifecycle owners. Windows reads/controls the VirtualDisplayManager; Linux
  keep-alive (a per-session pool) lands in a following increment (needs GPU-box
  validation).
- windows/manager.rs: additive snapshot() + force_release() (no behavior change
  to the on-glass-validated path).
- mgmt: GET /api/v1/display/state (live/kept displays) + POST /api/v1/display/release
  (tear down lingering/pinned now; refuses active). OpenAPI regenerated.
- web console: Virtual displays card gains a live-display list (polled) with
  per-row + release-all buttons and a linger countdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 20:32:03 +00:00
enricobuehler bbd98241e4 feat(vdisplay): display-management policy surface (Stage 0)
A user-configurable policy layer above the per-compositor VirtualDisplay
backends: keep-alive, topology, conflict, identity, layout, max-displays —
persisted to display-settings.json, editable from the web console, applied
per connect. Design: design/display-management.md.

Stage 0 stands up the surface and wires the two behaviors the existing code
can already express — the Windows monitor linger duration and the
"make the streamed output the sole desktop" topology — through it; every
other option is stored + echoed but not yet enforced (later stages). An
unconfigured host (no display-settings.json) keeps today's exact behavior.

- vdisplay/policy.rs: pure DisplayPolicy + 5 presets + JSON store (gpu-settings
  pattern) + EffectivePolicy; 9 unit tests.
- vdisplay.rs: resolve_topology(Auto); apply_session_env drives *_VIRTUAL_PRIMARY
  from the policy only when a settings file exists.
- windows/manager.rs: linger_ms() + should_isolate() read the policy when configured.
- mgmt: GET/PUT /api/v1/display/settings (bearer-only); PUT rejects keep_alive
  forever until the lifecycle stage. OpenAPI regenerated.
- web console: Host → Virtual displays card (preset picker + custom fields); en+de.
- docs-site: virtual-displays.md + configuration.md cross-links.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-04 19:44:18 +00:00
114 changed files with 12584 additions and 1179 deletions
+3 -3
View File
@@ -1,5 +1,5 @@
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via
# cargo-ndk for both shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
# cargo-ndk for all three shipping ABIs and assembles the debug APK (clients/android). Mirrors apple.yml
# but on a Linux runner — the NDK is cross-platform, so no self-hosted host is needed.
#
# Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
@@ -40,7 +40,7 @@ jobs:
fi
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
dirname "$RUSTUP" >> "$GITHUB_PATH"
"$RUSTUP" target add aarch64-linux-android x86_64-linux-android
"$RUSTUP" target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android
- name: Android SDK
uses: android-actions/setup-android@v3
@@ -98,7 +98,7 @@ jobs:
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: |
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks
# AAB for Play; a universal APK (both ABIs) for direct sideload/testing — same upload key.
# AAB for Play; a universal APK (all ABIs) for direct sideload/testing — same upload key.
./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
+2 -2
View File
@@ -28,8 +28,8 @@ jobs:
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock:
# registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — rust-toolchain.toml pins the floating "stable"
# channel, so the file alone wouldn't invalidate stale incremental state.
# carries the rustc version — resolved via `rustc --version` (below) rather than parsed
# from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
- name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4
Generated
+9 -9
View File
@@ -2129,7 +2129,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.7.4"
version = "0.8.0"
[[package]]
name = "lazy_static"
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.7.4"
version = "0.8.0"
dependencies = [
"punktfunk-core",
]
@@ -2908,7 +2908,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.7.4"
version = "0.8.0"
dependencies = [
"android_logger",
"jni",
@@ -2922,7 +2922,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.7.4"
version = "0.8.0"
dependencies = [
"anyhow",
"async-channel",
@@ -2945,7 +2945,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.7.4"
version = "0.8.0"
dependencies = [
"anyhow",
"async-channel",
@@ -2968,7 +2968,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.7.4"
version = "0.8.0"
dependencies = [
"aes-gcm",
"bytes",
@@ -2999,7 +2999,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.7.4"
version = "0.8.0"
dependencies = [
"aes",
"aes-gcm",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.7.4"
version = "0.8.0"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3085,7 +3085,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.7.4"
version = "0.8.0"
dependencies = [
"anyhow",
"ksni",
+1 -1
View File
@@ -17,7 +17,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package]
version = "0.7.4"
version = "0.8.0"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+5
View File
@@ -29,6 +29,11 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
- **Your device's exact mode.** For each client that connects, the host spins up a virtual display
sized to that device — 1080p60 to a laptop, 1440p120 to a desktop, 4K to a TV, all at once. No
letterboxing, no scaling, no rearranging your real monitors.
- **Displays you configure, not just create.** Keep a game's display (and the game) alive across
disconnects so a reconnect drops straight back in; make the stream your sole desktop or extend
alongside your monitors; let several devices become monitors of one desktop; keep each client's
scaling. One-click presets in the console — a dedicated couch box, a shared desktop, a multi-monitor
workstation. See [Virtual displays](docs-site/content/docs/virtual-displays.md).
- **A real virtual display on Windows, too.** On Linux the host uses per-compositor virtual outputs;
on Windows you get the same on-the-fly virtual display — at the client's exact mode, no physical
monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
+658 -2
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.6.0"
"version": "0.7.4"
},
"paths": {
"/api/v1/clients": {
@@ -138,6 +138,224 @@
}
}
},
"/api/v1/display/layout": {
"put": {
"tags": [
"display"
],
"summary": "Arrange virtual displays",
"description": "Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor\ngroup (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block\nand switched to manual mode; applied from the next connect (a live group re-applies on its next\nacquire). Locks in the current effective behavior as explicit fields, so arranging displays never\nsilently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2.",
"operationId": "setDisplayLayout",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplayLayoutRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Layout stored; the new settings state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplaySettingsState"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Layout could not be persisted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/release": {
"post": {
"tags": [
"display"
],
"summary": "Release kept virtual displays",
"description": "Tear down lingering/pinned displays now — so a physical-screen user gets their screen back\nwithout waiting out the linger. `slot` releases one; omit it to release all kept displays.\nActive (streaming) displays are never torn down here (that is session control).",
"operationId": "releaseDisplay",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseDisplayRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "The number of kept displays released",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ReleaseDisplayResult"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/settings": {
"get": {
"tags": [
"display"
],
"summary": "Display-management policy",
"description": "The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),\nevery preset's expansion, and which options this build enforces yet. See\n`design/display-management.md`.",
"operationId": "getDisplaySettings",
"responses": {
"200": {
"description": "Stored policy + preset expansions + enforced options",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplaySettingsState"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
},
"put": {
"tags": [
"display"
],
"summary": "Set the display-management policy",
"description": "Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a\nrunning session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is\nhonored (the display is Pinned; free it via `POST /display/release`).",
"operationId": "setDisplaySettings",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplayPolicy"
}
}
},
"required": true
},
"responses": {
"200": {
"description": "Policy stored; the new state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplaySettingsState"
}
}
}
},
"400": {
"description": "Malformed policy body",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"500": {
"description": "Policy could not be persisted",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/display/state": {
"get": {
"tags": [
"display"
],
"summary": "Live virtual displays",
"description": "The host's managed virtual displays right now — active (streaming), lingering (kept after\ndisconnect, counting down to teardown), or pinned (kept indefinitely). See\n`design/display-management.md`.",
"operationId": "getDisplayState",
"responses": {
"200": {
"description": "The live/kept virtual displays",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DisplayStateResponse"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/gpus": {
"get": {
"tags": [
@@ -1601,6 +1819,99 @@
"av1"
]
},
"ApiDisplayInfo": {
"type": "object",
"description": "One live or kept virtual display.",
"required": [
"slot",
"backend",
"mode",
"state",
"sessions",
"group",
"display_index",
"x",
"y",
"topology"
],
"properties": {
"backend": {
"type": "string",
"description": "Backend name (`pf-vdisplay`, `kwin`, …)."
},
"client": {
"type": [
"string",
"null"
],
"description": "Short client label, when the owner tracks it."
},
"display_index": {
"type": "integer",
"format": "int32",
"description": "This display's ordinal within its group, in acquire order (0-based).",
"minimum": 0
},
"expires_in_ms": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Milliseconds until a lingering display is torn down (absent when active/pinned).",
"minimum": 0
},
"group": {
"type": "integer",
"format": "int32",
"description": "Display group (shared desktop) id — several displays with the same group form one desktop (§6A).",
"minimum": 0
},
"identity_slot": {
"type": [
"integer",
"null"
],
"format": "int32",
"description": "Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous).",
"minimum": 0
},
"mode": {
"type": "string",
"description": "`WIDTHxHEIGHT@HZ`."
},
"sessions": {
"type": "integer",
"format": "int32",
"description": "Live sessions holding the display.",
"minimum": 0
},
"slot": {
"type": "integer",
"format": "int64",
"description": "Stable-enough id for the `/display/release` `slot` argument.",
"minimum": 0
},
"state": {
"type": "string",
"description": "`active` | `lingering` | `pinned`."
},
"topology": {
"type": "string",
"description": "Effective topology for this display's group (`extend` | `primary` | `exclusive`)."
},
"x": {
"type": "integer",
"format": "int32",
"description": "Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2)."
},
"y": {
"type": "integer",
"format": "int32",
"description": "Desktop-space top-left `y`."
}
}
},
"ApiError": {
"type": "object",
"description": "Error envelope for every non-2xx response.",
@@ -1909,6 +2220,146 @@
}
}
},
"DisplayLayoutRequest": {
"type": "object",
"description": "Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot\nid as a string (the same id `/display/state` reports as `identity_slot`).",
"properties": {
"positions": {
"type": "object",
"description": "`{\"<identity_slot>\": {\"x\": …, \"y\": …}}` — where each arranged display's top-left sits.",
"additionalProperties": {
"$ref": "#/components/schemas/Position"
},
"propertyNames": {
"type": "string"
}
}
}
},
"DisplayPolicy": {
"type": "object",
"description": "The user-facing display-management policy — what `display-settings.json` holds and what the mgmt\nAPI GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are\nignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a\nsingle [`EffectivePolicy`].",
"properties": {
"identity": {
"$ref": "#/components/schemas/Identity"
},
"keep_alive": {
"$ref": "#/components/schemas/KeepAlive"
},
"layout": {
"$ref": "#/components/schemas/Layout"
},
"max_displays": {
"type": "integer",
"format": "int32",
"description": "Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).",
"minimum": 0
},
"mode_conflict": {
"$ref": "#/components/schemas/ModeConflict"
},
"preset": {
"$ref": "#/components/schemas/Preset"
},
"topology": {
"$ref": "#/components/schemas/Topology"
},
"version": {
"type": "integer",
"format": "int32",
"description": "Schema version (currently 1) — lets a future field addition migrate rather than reject.",
"minimum": 0
}
}
},
"DisplaySettingsState": {
"type": "object",
"description": "Full display-management state for the console: the stored policy, every preset's expansion, the\nresolved effective policy, and which options this build actually enforces yet (Stage 0 wires\nkeep-alive linger + topology; the rest are stored but not yet acted on).",
"required": [
"settings",
"configured",
"effective",
"presets",
"enforced"
],
"properties": {
"configured": {
"type": "boolean",
"description": "True once a `display-settings.json` exists (the console has configured this host)."
},
"effective": {
"$ref": "#/components/schemas/EffectivePolicy",
"description": "The effective (preset-expanded) policy currently in force."
},
"enforced": {
"type": "array",
"items": {
"type": "string"
},
"description": "Option names this build enforces right now. All five axes are now acted on (keep_alive +\ntopology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console\nreads this to know which controls are live vs. \"coming soon\" (per-backend nuance, e.g. layout\nposition apply being KWin-only, is reported per display in `/display/state`)."
},
"presets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PresetInfo"
},
"description": "Every named preset and what it expands to (for the picker's preview)."
},
"settings": {
"$ref": "#/components/schemas/DisplayPolicy",
"description": "The stored policy (preset + custom fields), or the built-in default when unconfigured."
}
}
},
"DisplayStateResponse": {
"type": "object",
"description": "The host's managed virtual displays right now.",
"required": [
"displays"
],
"properties": {
"displays": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ApiDisplayInfo"
}
}
}
},
"EffectivePolicy": {
"type": "object",
"description": "The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call\nsites read, and what the mgmt API echoes as the \"currently in force\" policy. Pure output of\n[`DisplayPolicy::effective`].",
"required": [
"keep_alive",
"topology",
"mode_conflict",
"identity",
"layout",
"max_displays"
],
"properties": {
"identity": {
"$ref": "#/components/schemas/Identity"
},
"keep_alive": {
"$ref": "#/components/schemas/KeepAlive"
},
"layout": {
"$ref": "#/components/schemas/Layout"
},
"max_displays": {
"type": "integer",
"format": "int32",
"minimum": 0
},
"mode_conflict": {
"$ref": "#/components/schemas/ModeConflict"
},
"topology": {
"$ref": "#/components/schemas/Topology"
}
}
},
"GameEntry": {
"type": "object",
"description": "One title in the unified library, regardless of which store it came from.",
@@ -2099,6 +2550,72 @@
}
}
},
"Identity": {
"type": "string",
"description": "Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored\nat Stage 0; carriers wired from the identity stage.",
"enum": [
"shared",
"per-client",
"per-client-mode"
]
},
"KeepAlive": {
"oneOf": [
{
"type": "object",
"description": "Tear the display down at session end (today's default on every backend but Windows, which\nlingers 10 s).",
"required": [
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"off"
]
}
}
},
{
"type": "object",
"description": "Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect\ninside the window reuses it.",
"required": [
"seconds",
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"duration"
]
},
"seconds": {
"type": "integer",
"format": "int32",
"description": "Linger window in seconds.",
"minimum": 0
}
}
},
{
"type": "object",
"description": "Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).\n**Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.",
"required": [
"mode"
],
"properties": {
"mode": {
"type": "string",
"enum": [
"forever"
]
}
}
}
],
"description": "How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)\nsurvives after the last client session detaches. Serialized as an object tagged on `mode`\n(`{\"mode\":\"off\"}` / `{\"mode\":\"duration\",\"seconds\":300}` / `{\"mode\":\"forever\"}`) so the web form\nand the OpenAPI schema stay simple."
},
"LaunchSpec": {
"type": "object",
"description": "How the host would launch a title (consumed by the session launcher in a later step). Kept\nopen-ended so new stores slot in: `steam_appid` → `steam steam://rungameid/<value>`;\n`command` → run `<value>` nested in a gamescope session.",
@@ -2118,6 +2635,32 @@
}
}
},
"Layout": {
"type": "object",
"description": "Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by\nidentity-slot id (string keys for stable JSON).",
"properties": {
"mode": {
"$ref": "#/components/schemas/LayoutMode"
},
"positions": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Position"
},
"propertyNames": {
"type": "string"
}
}
}
},
"LayoutMode": {
"type": "string",
"description": "How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from\nthe multi-monitor stage.",
"enum": [
"auto-row",
"manual"
]
},
"LocalSummary": {
"type": "object",
"description": "Non-sensitive host status for the local tray icon: counts and booleans only — no PIN values,\nno fingerprints, no device names. Served unauthenticated to LOOPBACK peers only (see\n`require_auth`): the bearer-token file is SYSTEM/Administrators-DACL'd on Windows, so the\nper-user tray process cannot authenticate — this narrow read-only route is its status source.",
@@ -2128,13 +2671,20 @@
"paired_clients",
"native_paired_clients",
"pin_pending",
"pending_approvals"
"pending_approvals",
"kept_displays"
],
"properties": {
"audio_streaming": {
"type": "boolean",
"description": "True while the audio stream thread is running."
},
"kept_displays": {
"type": "integer",
"format": "int32",
"description": "Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned\n(`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is\nheld; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.",
"minimum": 0
},
"native_paired_clients": {
"type": "integer",
"format": "int32",
@@ -2242,6 +2792,16 @@
}
}
},
"ModeConflict": {
"type": "string",
"description": "Admission when a *different* client connects while a display/session is already live and asks for\na different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.",
"enum": [
"separate",
"steal",
"join",
"reject"
]
},
"NativeClient": {
"type": "object",
"description": "A paired native (punktfunk/1) client.",
@@ -2439,6 +2999,88 @@
}
}
},
"Position": {
"type": "object",
"description": "A desktop-space offset for a display (top-left origin).",
"required": [
"x",
"y"
],
"properties": {
"x": {
"type": "integer",
"format": "int32"
},
"y": {
"type": "integer",
"format": "int32"
}
}
},
"Preset": {
"type": "string",
"description": "A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any\nother preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).",
"enum": [
"custom",
"default",
"gaming-rig",
"shared-desktop",
"hotdesk",
"workstation"
]
},
"PresetInfo": {
"type": "object",
"description": "One preset's human-facing description + the fields it expands to, so the console can render a\npreset picker with an accurate \"what this does\" preview without hardcoding the expansion.",
"required": [
"id",
"summary",
"fields"
],
"properties": {
"fields": {
"$ref": "#/components/schemas/EffectivePolicy",
"description": "The effective policy this preset expands to (the same fields a `custom` policy carries)."
},
"id": {
"type": "string",
"description": "The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`)."
},
"summary": {
"type": "string",
"description": "One-line story shown next to the option."
}
}
},
"ReleaseDisplayRequest": {
"type": "object",
"description": "Request body for `releaseDisplay`.",
"properties": {
"slot": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "Slot to release (see `state`); omit to release **all** kept displays.",
"minimum": 0
}
}
},
"ReleaseDisplayResult": {
"type": "object",
"description": "Result of a `/display/release`.",
"required": [
"released"
],
"properties": {
"released": {
"type": "integer",
"description": "Number of kept displays torn down.",
"minimum": 0
}
}
},
"RuntimeStatus": {
"type": "object",
"description": "Live host status (changes as clients launch/end sessions).",
@@ -2740,6 +3382,16 @@
"example": "1234"
}
}
},
"Topology": {
"type": "string",
"description": "What the host does to the box's display topology while managed virtual displays are up.",
"enum": [
"auto",
"extend",
"primary",
"exclusive"
]
}
},
"securitySchemes": {
@@ -2763,6 +3415,10 @@
"name": "gpu",
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"
},
{
"name": "display",
"description": "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"
},
{
"name": "clients",
"description": "Paired Moonlight client management"
+4 -2
View File
@@ -16,7 +16,9 @@ couch (D-pad / gamepad focus navigation).
pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
- **Compose UI** — Connect / Settings / Stream screens with Material You theming.
Built for `arm64-v8a` + `x86_64`.
Built for `arm64-v8a` + `armeabi-v7a` + `x86_64` — the 32-bit `armeabi-v7a` slice is what keeps the
app installable on the many 32-bit Google TV / Android TV streamers (Walmart onn. 4K, Chromecast with
Google TV, budget Amlogic boxes) that otherwise reject a 64-bit-only build as "not compatible".
## Get it
@@ -54,7 +56,7 @@ kit/ :kit — NativeBridge · native mDNS discovery · Gamepad · K
**Prerequisites:** Android SDK + **NDK r30** (`30.0.14904198`), `platforms;android-37.0`,
`build-tools;37.0.0`, **`cmake;3.22.1`** (builds libopus); **JDK 21** (AGP 9.2 runs on JDK 1721, not
a newer default); Rust with `rustup target add aarch64-linux-android x86_64-linux-android` and
a newer default); Rust with `rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android` and
`cargo install cargo-ndk`. Toolchain is pinned (AGP 9.2 · Gradle 9.4.1 · Kotlin 2.3.21 · Compose BOM
2026.05.01 · compileSdk 37 · minSdk 31).
+33 -4
View File
@@ -22,14 +22,34 @@ android {
}
applicationId = "io.unom.punktfunk"
minSdk = 31
// Android 9. Reaches older Android TV boxes (e.g. Amlogic streamers still on Android 911);
// the handful of API 31+ APIs we use are runtime-gated (Material You → brand palette, rumble
// → legacy Vibrator, NEARBY_WIFI/lights/ADPF already gated), so nothing is lost above 28.
minSdk = 28
targetSdk = 36
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
versionCode = vCode?.toInt() ?: 1
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a
// canary string). versionCode stays the monotonic run number (Play rejects regressions).
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2"
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
// Local dev (no VERSION_NAME) falls back to the workspace version from the root Cargo.toml —
// the single source of truth — so an on-device build shows the real current version, not a
// stale placeholder.
val workspaceVersion = runCatching {
project.rootProject.file("../../Cargo.toml").readLines()
.dropWhile { !it.trim().startsWith("[workspace.package]") }
.firstOrNull { it.trim().startsWith("version") }
?.substringAfter('=')?.trim()?.trim('"')
}.getOrNull()
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME"))
?: workspaceVersion ?: "0.0.0"
// Ship 32-bit armeabi-v7a alongside 64-bit arm64-v8a: many Google TV / Android TV streamers
// (Walmart onn. 4K, Chromecast with Google TV, budget Amlogic boxes) run a 32-bit Android
// userspace, and because this app carries native code, Google Play (and a sideload installer)
// filters it as "not compatible" on those devices unless an armeabi-v7a variant is present.
// x86_64 stays for the emulator. Google keeps delivering to 32-bit TV devices (see the Aug
// 2025 "64-bit app compatibility for Google TV and Android TV" post) — the 64-bit lib is the
// required half; the 32-bit lib is what actually reaches the boxes people report failing.
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
}
signingConfigs {
@@ -97,9 +117,18 @@ dependencies {
implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.material:material-icons-core") // bottom-bar tab icons
implementation("androidx.compose.material:material-icons-core") // bottom-bar / rail tab icons
implementation("androidx.compose.material:material-icons-extended") // settings-category icons
debugImplementation("androidx.compose.ui:ui-tooling")
// Cover-art loading for the game-library coverflow. Coil 2.x uses OkHttp under the hood, so we
// feed it the same mTLS OkHttpClient the library fetch uses (reaching the host's own art proxy).
implementation("io.coil-kt:coil-compose:2.7.0")
// Real backdrop blur for the floating console legends (RenderEffect on API 31+, a translucent
// scrim below). The gamepad UI's frosted pills sample + blur whatever scrolls behind them.
implementation("dev.chrisbanes.haze:haze:1.6.0")
// Android TV components (we target phone + TV) land in the TV-UI milestone:
// implementation("androidx.tv:tv-material:1.1.0")
// The manifest already declares leanback so the scaffold installs on TV.
@@ -36,6 +36,7 @@
<application
android:allowBackup="false"
android:appCategory="game"
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
@@ -0,0 +1,93 @@
Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -2,40 +2,62 @@ package io.unom.punktfunk
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.models.Tab
@Composable
fun App() {
fun App(forceGamepadUi: Boolean = false) {
val context = LocalContext.current
val settingsStore = remember { SettingsStore(context) }
var settings by remember { mutableStateOf(settingsStore.load()) }
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
var tab by remember { mutableStateOf(Tab.Connect) }
// Console (gamepad) mode mirrors the Apple client: the setting AND (a pad is attached OR this is
// a TV OR the dev force flag). Flips live as controllers connect/disconnect.
val tv = remember { isTvDevice(context) }
val controllerConnected by rememberControllerConnected()
val gamepadUi = gamepadUiActive(settings.gamepadUiEnabled, controllerConnected, tv, forceGamepadUi)
AnimatedContent(
targetState = streamHandle != 0L,
transitionSpec = {
@@ -46,46 +68,154 @@ fun App() {
if (isStreaming) {
// Immersive: the stream takes the whole screen, no bottom bar.
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
} else if (gamepadUi) {
GamepadShell(
settings = settings,
onSettingsChange = { settings = it; settingsStore.save(it) },
onConnected = { streamHandle = it },
)
} else {
Scaffold(
bottomBar = {
NavigationBar {
Tab.entries.forEach { t ->
NavigationBarItem(
selected = tab == t,
onClick = { tab = t },
icon = { Icon(t.icon, contentDescription = t.label) },
label = { Text(t.label) },
)
}
}
},
) { innerPadding ->
Box(Modifier.fillMaxSize().padding(innerPadding)) {
AnimatedContent(
targetState = tab,
transitionSpec = {
if (targetState.ordinal > initialState.ordinal) {
// Adaptive nav: a bottom bar on phones; on tablets / large windows a side NavigationRail
// with its items centred vertically (the common Android tablet idiom, mirroring iPad's
// side navigation). A short landscape phone keeps the bottom bar (rail needs height too).
// Tabs slide along the axis the nav sits on: horizontally with the bottom bar (phone),
// vertically with the side rail (tablet), so the motion tracks the direction you moved.
val tabContent: @Composable (vertical: Boolean) -> Unit = { vertical ->
AnimatedContent(
targetState = tab,
transitionSpec = {
val forward = targetState.ordinal > initialState.ordinal
when {
vertical && forward ->
slideInVertically { it } + fadeIn() togetherWith
slideOutVertically { -it } + fadeOut()
vertical ->
slideInVertically { -it } + fadeIn() togetherWith
slideOutVertically { it } + fadeOut()
forward ->
slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut()
} else {
else ->
slideInHorizontally { -it } + fadeIn() togetherWith
slideOutHorizontally { it } + fadeOut()
}
},
label = "TabTransition"
) { targetTab ->
when (targetTab) {
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
Tab.Settings -> SettingsScreen(
initial = settings,
onChange = { settings = it; settingsStore.save(it) },
onBack = { tab = Tab.Connect },
)
}
}
}
BoxWithConstraints(Modifier.fillMaxSize()) {
if (maxWidth >= 600.dp && maxHeight >= 480.dp) {
Row(Modifier.fillMaxSize()) {
NavigationRail(Modifier.fillMaxHeight()) {
Spacer(Modifier.weight(1f)) // centre the rail items vertically
Tab.entries.forEach { t ->
NavigationRailItem(
selected = tab == t,
onClick = { tab = t },
icon = { Icon(t.icon, contentDescription = t.label) },
label = { Text(t.label) },
)
}
Spacer(Modifier.weight(1f))
}
// The rail handles its own insets; the content pane insets itself (the screens
// don't, since they used to rely on the Scaffold's padding).
Box(Modifier.weight(1f).fillMaxHeight().systemBarsPadding()) { tabContent(true) }
}
} else {
Scaffold(
bottomBar = {
NavigationBar {
Tab.entries.forEach { t ->
NavigationBarItem(
selected = tab == t,
onClick = { tab = t },
icon = { Icon(t.icon, contentDescription = t.label) },
label = { Text(t.label) },
)
}
}
},
label = "TabTransition"
) { targetTab ->
when (targetTab) {
Tab.Connect -> ConnectScreen(settings = settings, onConnected = { streamHandle = it })
Tab.Settings -> SettingsScreen(
initial = settings,
onChange = { settings = it; settingsStore.save(it) },
onBack = { tab = Tab.Connect },
)
}
) { innerPadding ->
Box(Modifier.fillMaxSize().padding(innerPadding)) { tabContent(false) }
}
}
}
}
}
}
/** Which console screen the gamepad shell is showing. */
private enum class GamepadScreen { Home, Settings, Library }
/**
* The console (gamepad) shell — the Android mirror of the Apple client's ContentView gamepad branch:
* a full-screen host carousel with X → Settings and Y → a saved host's library, all sharing
* [ConnectScreen]'s connect logic. No bottom bar; navigation is button-driven.
*/
@Composable
fun GamepadShell(
settings: Settings,
onSettingsChange: (Settings) -> Unit,
onConnected: (Long) -> Unit,
) {
val context = LocalContext.current
var screen by remember { mutableStateOf(GamepadScreen.Home) }
var libraryHost by remember { mutableStateOf<io.unom.punktfunk.kit.security.KnownHost?>(null) }
// On a TV, shrink the 10-foot UI so its elements aren't oversized. Density-aware: expand the
// effective dp footprint to at least CONSOLE_TV_MIN_WIDTH_DP (→ smaller elements) ONLY when the
// panel reports fewer dp than that; a low-density TV that's already spacious, and every phone /
// tablet, keep their real density unchanged. This is the "based on pixel density" scale the layout
// wanted — one uniform factor across text, cards, spacing, and insets.
val isTv = remember { isTvDevice(context) }
val baseDensity = LocalDensity.current
val screenWidthPx = LocalConfiguration.current.screenWidthDp * baseDensity.density
val fitDensity = screenWidthPx / CONSOLE_TV_MIN_WIDTH_DP
val consoleDensity = if (isTv && fitDensity < baseDensity.density) fitDensity else baseDensity.density
CompositionLocalProvider(LocalDensity provides Density(consoleDensity, baseDensity.fontScale)) {
// Cross-fade between console screens so switches are smooth. Each slot's controller nav is gated
// on being the CURRENT target (`s == screen`), so during the fade only the incoming screen drives
// the pad. All screens pin their legend at the same ConsoleLegendInset, so it reads as fixed while
// the content behind it fades.
Crossfade(targetState = screen, animationSpec = tween(240), label = "consoleScreen") { s ->
when (s) {
GamepadScreen.Home -> ConnectScreen(
settings = settings,
onConnected = onConnected,
gamepadUi = true,
onOpenSettings = { screen = GamepadScreen.Settings },
onOpenLibrary = { host -> libraryHost = host; screen = GamepadScreen.Library },
navGate = s == screen,
)
GamepadScreen.Settings -> GamepadSettingsScreen(
initial = settings,
onChange = onSettingsChange,
onBack = { screen = GamepadScreen.Home },
navActive = s == screen,
)
GamepadScreen.Library -> libraryHost?.let { host ->
LibraryScreen(
host = host,
onBack = { screen = GamepadScreen.Home; libraryHost = null },
navActive = s == screen,
)
} ?: run { screen = GamepadScreen.Home }
}
}
}
}
/** Minimum effective dp width the console UI targets on a TV (bigger → the 10-foot UI shrinks). */
private const val CONSOLE_TV_MIN_WIDTH_DP = 1180f
@@ -9,7 +9,9 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
@@ -33,6 +35,7 @@ import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -320,32 +323,75 @@ internal fun AwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
}
/**
* Rename a saved host's label (discovered hosts are named by mDNS; this is how you give one a
* friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field.
* Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
* host's mDNS advert while it's online, but this is where you can enter or correct it (e.g. to wake a
* host you've only ever reached by address). [suggestedMacs] prefills the field from the live advert
* when nothing's been learned yet. Keyed by the host so reopening resets the fields. Mirrors the
* Apple client's edit form.
*/
@Composable
internal fun RenameHostDialog(
internal fun EditHostDialog(
target: KnownHost,
onRename: (String) -> Unit,
suggestedMacs: List<String>,
onSave: (KnownHost) -> Unit,
onDismiss: () -> Unit,
) {
var newName by remember(target) { mutableStateOf(target.name) }
var name by remember(target) { mutableStateOf(target.name) }
var address by remember(target) { mutableStateOf(target.address) }
var port by remember(target) { mutableStateOf(target.port.toString()) }
var mac by remember(target) {
mutableStateOf(target.mac.ifEmpty { suggestedMacs }.joinToString(", "))
}
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Rename host") },
title = { Text("Edit host") },
text = {
OutlinedTextField(
value = newName,
onValueChange = { newName = it },
label = { Text("Name") },
placeholder = { Text(target.address) },
singleLine = true,
)
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
placeholder = { Text(target.address) },
singleLine = true,
)
OutlinedTextField(
value = address,
onValueChange = { address = it },
label = { Text("Address") },
singleLine = true,
)
OutlinedTextField(
value = port,
onValueChange = { v -> port = v.filter { it.isDigit() }.take(5) },
label = { Text("Port") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
)
OutlinedTextField(
value = mac,
onValueChange = { mac = it },
label = { Text("Wake-on-LAN MAC") },
placeholder = { Text("auto-filled when the host is seen") },
singleLine = true,
)
}
},
confirmButton = {
TextButton(
enabled = newName.isNotBlank(),
onClick = { onRename(newName.trim()) },
enabled = address.isNotBlank(),
onClick = {
onSave(
target.copy(
name = name.trim().ifEmpty { target.address },
address = address.trim(),
port = port.toIntOrNull() ?: target.port,
mac = KnownHostStore.parseMacs(mac),
),
)
},
) { Text("Save") }
},
dismissButton = {
@@ -84,7 +84,17 @@ private class RequestAccessState(val target: PendingTrust) {
}
@Composable
fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
fun ConnectScreen(
settings: Settings,
onConnected: (Long) -> Unit,
// Console (gamepad) mode: render the host carousel instead of the touch grid, sharing all of this
// screen's connect/trust/discovery logic. [onOpenSettings]/[onOpenLibrary] are the X/Y actions the
// gamepad shell owns (the touch UI reaches Settings via the bottom bar and has no library button).
gamepadUi: Boolean = false,
onOpenSettings: () -> Unit = {},
onOpenLibrary: (KnownHost) -> Unit = {},
navGate: Boolean = true, // false while the console home is cross-fading out
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
var host by remember { mutableStateOf("") }
@@ -124,6 +134,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val identityStore = remember { IdentityStore(context) }
val knownHostStore = remember { KnownHostStore(context) }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) }
// Wakes a sleeping saved host and waits for it to reappear on mDNS before dialing (its overlay
// rides over both the touch and console home). Fire-and-forget WoL isn't enough — a cold boot can
// take a minute-plus to advertise again.
val waker = remember { WakeController(scope) }
// Learn wake MAC(s) from live adverts for hosts we've saved (parity with the desktop clients),
// so we can Wake-on-LAN them once they sleep. Runs only when the discovered set changes; the
// prefs write is guarded (no-op when unchanged), and we refresh the saved list only if a MAC
@@ -156,8 +170,11 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
// A saved host whose label is being edited (the Rename dialog).
var renameTarget by remember { mutableStateOf<KnownHost?>(null) }
// A saved host being edited (name / address / port / MAC).
var editTarget by remember { mutableStateOf<KnownHost?>(null) }
// A saved host whose console options menu (Wake / Edit / Forget) is open — reached with Up on the
// carousel (the console counterpart of the touch host card's overflow menu).
var optionsTarget by remember { mutableStateOf<KnownHost?>(null) }
// Discovered hosts not already saved — a saved host (paired or TOFU) belongs in "Saved hosts",
// not also in "Discovered", so we hide the overlap (matched by fingerprint when both carry it, so
@@ -184,25 +201,16 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
// Issue the actual connect with identity + (optional) pin. On a TOFU connect (pinHex null),
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes
// straight through and it appears in the saved-hosts list.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
val id = identity
if (id == null) {
// The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// the host presented (as an unpaired known host) so the next connect goes straight through and it
// appears in the saved-hosts list.
fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
val id = identity ?: run {
status = "Identity not ready yet — try again in a moment"
return
}
connecting = true
status = "Connecting to $targetHost:$targetPort"
// Auto-wake: reconnecting to a saved host that may be asleep. If we learned its MAC while it
// was online and it isn't currently advertising, fire a magic packet first — the connect's
// own timeout gives a woken host time to come up (harmless if it's already awake).
knownHostStore.get(targetHost, targetPort)?.mac
?.takeIf { it.isNotEmpty() && discovered.none { d -> d.host == targetHost && d.port == targetPort } }
?.let { macs ->
scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(macs.joinToString(","), targetHost) }
}
discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch {
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS)
@@ -222,6 +230,47 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
}
}
// Wake-aware connect. If the target is a saved host with a learned MAC that ISN'T currently
// advertising (asleep/off), wake it and WAIT for it to reappear on mDNS (WakeController shows the
// "Waking…" overlay) before dialing — discovery stays running meanwhile so we can see it come
// back. A fire-and-forget packet + the connect timeout wasn't enough for a cold boot. Otherwise
// dial straight through.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
if (identity == null) {
status = "Identity not ready yet — try again in a moment"
return
}
val kh = knownHostStore.get(targetHost, targetPort)
val macs = kh?.mac ?: emptyList()
// "Up" = a live advert that is THIS host — matched by fingerprint first (so it survives a DHCP
// address change on a cold boot), else by address:port. Returns the CURRENT advert so we can
// dial its live address rather than the stale saved one.
fun liveAdvert(): DiscoveredHost? =
if (kh != null) discovered.firstOrNull { kh.matches(it) }
else discovered.firstOrNull { it.host == targetHost && it.port == targetPort }
if (macs.isNotEmpty() && liveAdvert() == null) {
waker.start(
hostName = name,
connectsAfter = true,
macs = macs,
lastIp = targetHost,
isOnline = { liveAdvert() != null },
onOnline = {
val live = liveAdvert()
// Woke back on a new address? Re-key the saved record so it (and future connects)
// point at the live one, then dial there.
if (live != null && kh != null && (live.host != kh.address || live.port != kh.port)) {
knownHostStore.update(kh.address, kh.port, kh.copy(address = live.host, port = live.port))
savedHosts = knownHostStore.all()
}
doConnectDirect(live?.host ?: targetHost, live?.port ?: targetPort, name, pinHex)
},
)
} else {
doConnectDirect(targetHost, targetPort, name, pinHex)
}
}
// The no-PIN "request access" path (delegated approval): open a normal identified connect that
// the host PARKS until the operator clicks Approve in its console/web UI, showing a cancelable
// "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
@@ -304,7 +353,62 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
var showManualSheet by remember { mutableStateOf(false) }
Box(Modifier.fillMaxSize()) {
if (gamepadUi) {
// Console mode: the host carousel (saved → discovered → Add Host), driven by the pad. Shares
// every action above; the trailing Add Host tile opens the same manual-entry sheet.
val tiles = buildList {
savedHosts.forEach { kh ->
add(
HomeTile(
id = "saved-${kh.address}:${kh.port}",
title = kh.name,
subtitle = "${kh.address}:${kh.port}",
filled = true,
online = discovered.any { it.host == kh.address && it.port == kh.port },
paired = kh.paired,
knownHost = kh,
activate = { connect(kh.address, kh.port) },
),
)
}
discoveredUnsaved.forEach { dh ->
add(
HomeTile(
id = "disc-${dh.host}:${dh.port}",
title = dh.name,
subtitle = "${dh.host}:${dh.port}",
online = true,
activate = { connect(dh.host, dh.port, dh) },
),
)
}
add(
HomeTile(
id = "add",
title = "Add Host",
subtitle = "Register a host by address",
isAdd = true,
activate = { showManualSheet = true },
),
)
}
GamepadHome(
tiles = tiles,
libraryEnabled = settings.libraryEnabled,
controllerName = io.unom.punktfunk.kit.Gamepad.firstPad()?.name,
// Stop the carousel from consuming the pad while a sheet/dialog/overlay owns the screen,
// while a connect is in flight (else a second A launches a concurrent connect that leaks a
// handle — the touch grid guards the same way with enabled=!connecting), or while the whole
// console home is cross-fading out.
navActive = navGate && !connecting && !showManualSheet && pendingTrust == null &&
awaiting == null && editTarget == null && optionsTarget == null && waker.waking == null,
onActivate = { it.activate() },
onOpenLibrary = { it.knownHost?.let(onOpenLibrary) },
onOpenSettings = onOpenSettings,
onOptions = { it.knownHost?.let { kh -> optionsTarget = kh } },
)
} else {
Box(Modifier.fillMaxSize()) {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp),
modifier = Modifier.fillMaxSize(),
@@ -385,13 +489,22 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
},
onRename = { renameTarget = kh },
// Explicit wake: offered only when the host is offline and we have a MAC to
// target (a tap-to-connect already auto-wakes an offline saved host).
onWake = if (kh.mac.isNotEmpty() &&
discovered.none { it.host == kh.address && it.port == kh.port }
) {
{ scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address) } }
onEdit = { editTarget = kh },
// Explicit wake-only: offered when the host is offline and we have a MAC. Runs
// through the WakeController so it shows the "Waking…" overlay and waits for
// the host to come online (matched by fingerprint, so a new DHCP address on a
// cold boot still counts as "up") rather than firing a single silent packet.
onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) {
{
waker.start(
hostName = kh.name,
connectsAfter = false,
macs = kh.mac,
lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
}
} else {
null
},
@@ -451,80 +564,134 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
.align(Alignment.BottomEnd)
.padding(20.dp),
)
}
}
if (showManualSheet) {
AddHostSheet(
hostName = hostName,
onHostNameChange = { hostName = it },
host = host,
onHostChange = { host = it },
port = port,
onPortChange = { port = it },
connecting = connecting,
modeLabel = "$w×$h@$hz",
onDismiss = { showManualSheet = false },
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
)
}
pendingTrust?.let { pt ->
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog(
pt = pt,
onTrust = { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) },
onPairInstead = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
onDismiss = { pendingTrust = null },
)
PendingTrust.Kind.FP_CHANGED -> FingerprintChangedDialog(
pt = pt,
onRepair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
onDismiss = { pendingTrust = null },
)
PendingTrust.Kind.REQUEST_ACCESS -> RequestAccessDialog(
pt = pt,
onRequestAccess = { pendingTrust = null; requestAccess(pt) },
onUsePin = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) },
onDismiss = { pendingTrust = null },
)
PendingTrust.Kind.PAIR -> PairPinDialog(
pt = pt,
identity = identity,
onPaired = { fp ->
// Verified host fp — save as a paired known host, then connect pinned.
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp)
if (gamepadUi) {
// Console add-host: field list + on-screen controller keyboard. "Add" connects (which
// saves the host on TOFU/pair), exactly like the touch sheet's Connect.
GamepadAddHostScreen(
onAdd = { n, addr, p ->
showManualSheet = false
connect(addr, p, manualName = n)
},
onDismiss = { pendingTrust = null },
onDismiss = { showManualSheet = false },
)
} else {
AddHostSheet(
hostName = hostName,
onHostNameChange = { hostName = it },
host = host,
onHostChange = { host = it },
port = port,
onPortChange = { port = it },
connecting = connecting,
modeLabel = "$w×$h@$hz",
onDismiss = { showManualSheet = false },
onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
)
}
}
pendingTrust?.let { pt ->
// Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
val onSavePaired = { fp: String ->
knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
savedHosts = knownHostStore.all()
pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp)
}
when (pt.kind) {
PendingTrust.Kind.TRUST_NEW ->
if (gamepadUi) GamepadTrustNewDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
else TrustNewHostDialog(pt, { pendingTrust = null; doConnect(pt.host, pt.port, pt.name, null) }, onPair, { pendingTrust = null })
PendingTrust.Kind.FP_CHANGED ->
if (gamepadUi) GamepadFingerprintChangedDialog(pt, onPair, { pendingTrust = null })
else FingerprintChangedDialog(pt, onPair, { pendingTrust = null })
PendingTrust.Kind.REQUEST_ACCESS ->
if (gamepadUi) GamepadRequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
else RequestAccessDialog(pt, { pendingTrust = null; requestAccess(pt) }, onPair, { pendingTrust = null })
PendingTrust.Kind.PAIR ->
if (gamepadUi) GamepadPairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
else PairPinDialog(pt, identity, onSavePaired, { pendingTrust = null })
}
}
awaiting?.let { req ->
AwaitingApprovalDialog(
hostLabel = req.target.name,
onCancel = {
req.cancelled.set(true)
awaiting = null
connecting = false
discovery.start() // the request may still be pending on the host; keep scanning
val onCancel = {
req.cancelled.set(true)
awaiting = null
connecting = false
discovery.start() // the request may still be pending on the host; keep scanning
}
if (gamepadUi) GamepadAwaitingApprovalDialog(req.target.name, onCancel)
else AwaitingApprovalDialog(hostLabel = req.target.name, onCancel = onCancel)
}
// Console host options (Up on a saved carousel tile): Wake / Edit / Forget.
optionsTarget?.let { kh ->
val offline = discovered.none { kh.matches(it) }
GamepadHostOptionsDialog(
hostName = kh.name,
canWake = kh.mac.isNotEmpty() && offline,
onWake = {
optionsTarget = null
waker.start(
hostName = kh.name, connectsAfter = false, macs = kh.mac, lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
},
// A saved host always has a library (it's a knownHost) → offer it when the setting's on,
// so a TV remote reaches the library here instead of via the Y face button.
onLibrary = if (settings.libraryEnabled) {
{ optionsTarget = null; onOpenLibrary(kh) }
} else {
null
},
onEdit = { optionsTarget = null; editTarget = kh },
onForget = {
knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all()
optionsTarget = null
},
onDismiss = { optionsTarget = null },
)
}
renameTarget?.let { kh ->
RenameHostDialog(
target = kh,
onRename = { newName ->
knownHostStore.rename(kh.address, kh.port, newName)
savedHosts = knownHostStore.all()
renameTarget = null
},
onDismiss = { renameTarget = null },
)
editTarget?.let { kh ->
// Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
// `discovery.hosts.first { host.matches($0) }?.macAddresses`.
val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
val onSaveHost: (KnownHost) -> Unit = { updated ->
knownHostStore.update(kh.address, kh.port, updated)
savedHosts = knownHostStore.all()
editTarget = null
}
if (gamepadUi) {
// Console edit: the same field list + on-screen keyboard as Add-Host, seeded from the
// host with an extra MAC row; the action SAVES instead of connecting.
GamepadAddHostScreen(
onAdd = { _, _, _ -> },
onDismiss = { editTarget = null },
editHost = kh,
suggestedMacs = suggested,
onSave = onSaveHost,
)
} else {
EditHostDialog(
target = kh,
suggestedMacs = suggested,
onSave = onSaveHost,
onDismiss = { editTarget = null },
)
}
}
// Topmost: the "Waking…" overlay rides over both the touch grid and the console home.
WakeOverlay(waker, gamepadUi)
}
/**
@@ -1,6 +1,7 @@
package io.unom.punktfunk
import android.hardware.input.InputManager
import android.os.Build
import android.os.CombinedVibration
import android.os.Handler
import android.os.Looper
@@ -244,7 +245,7 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val canRumble = dev.vibratorManager.vibratorIds.isNotEmpty()
val canRumble = deviceHasVibrator(dev)
if (canRumble) {
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
} else {
@@ -318,11 +319,27 @@ private fun Group(title: String, content: @Composable ColumnScope.() -> Unit) {
}
}
/** Whether the controller reports a rumble motor — via VibratorManager (API 31+) or the legacy Vibrator. */
private fun deviceHasVibrator(dev: InputDevice): Boolean =
if (Build.VERSION.SDK_INT >= 31) {
dev.vibratorManager.vibratorIds.isNotEmpty()
} else {
@Suppress("DEPRECATION")
dev.vibrator.hasVibrator()
}
private fun testRumble(dev: InputDevice) {
val vm = dev.vibratorManager
if (vm.vibratorIds.isEmpty()) return
runCatching {
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
if (Build.VERSION.SDK_INT >= 31) {
val vm = dev.vibratorManager
if (vm.vibratorIds.isEmpty()) return
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200)))
} else {
@Suppress("DEPRECATION")
val v = dev.vibrator
if (!v.hasVibrator()) return
v.vibrate(VibrationEffect.createOneShot(300, 200))
}
}
}
@@ -0,0 +1,467 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.OutlinedTextField
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
// The gamepad-driven "Add Host" screen — the Android mirror of the Apple client's GamepadAddHostView
// + GamepadKeyboard: three field rows (name / address / port) plus an Add action, navigated with the
// vertical focus list; A on a field opens the on-screen keyboard so a host can be registered end to
// end from the couch. One GamepadNavEffect2D owns BOTH modes (list vs keyboard) so they never fight
// over the shared input probes. B peels one layer: close the keyboard, then cancel the screen.
// Keyboard grid: digits, qwerty letters, hostname/address punctuation, then space / delete / done.
private val KB_CHAR_ROWS = listOf("1234567890", "qwertyuiop", "asdfghjkl-", "zxcvbnm._:")
private const val KB_ACTIONS_ROW = 4 // index of the [space, delete, done] row
private const val KB_ROWS = 5
private class Field(val id: String, val label: String, val value: String, val placeholder: String)
@Composable
fun GamepadAddHostScreen(
onAdd: (name: String, address: String, port: Int) -> Unit,
onDismiss: () -> Unit,
// Non-null → EDIT mode: fields seed from this host, a MAC row is added, and the action SAVES the
// edited record via [onSave] instead of connecting. [suggestedMacs] prefills a not-yet-learned MAC.
editHost: KnownHost? = null,
suggestedMacs: List<String> = emptyList(),
onSave: ((KnownHost) -> Unit)? = null,
) {
val context = LocalContext.current
val isTv = remember { isTvDevice(context) }
val isEdit = editHost != null
val title = if (isEdit) "Edit Host" else "Add Host"
val actionLabel = if (isEdit) "Save" else "Add Host"
var name by remember { mutableStateOf(editHost?.name ?: "") }
var address by remember { mutableStateOf(editHost?.address ?: "") }
var port by remember { mutableStateOf(editHost?.port?.toString() ?: "9777") }
var mac by remember { mutableStateOf(editHost?.mac?.ifEmpty { suggestedMacs }?.joinToString(", ") ?: "") }
val canAdd = address.isNotBlank() && (port.toIntOrNull() ?: 0) > 0
fun commit() {
if (isEdit && editHost != null && onSave != null) {
onSave(
editHost.copy(
name = name.trim().ifEmpty { editHost.address },
address = address.trim(),
port = port.toIntOrNull() ?: editHost.port,
mac = KnownHostStore.parseMacs(mac),
),
)
} else {
onAdd(name.trim(), address.trim(), port.toIntOrNull() ?: 9777)
}
}
// On a TV the OS provides a leanback on-screen keyboard for text fields, so use real (focusable)
// text fields + the system IME there. Our controller keyboard is for a phone-with-controller,
// where the phone's own soft keyboard needs a touch a pad can't provide.
if (isTv) {
TvAddHostForm(
title = title, actionLabel = actionLabel,
name = name, onName = { name = it },
address = address, onAddress = { address = it },
port = port, onPort = { port = it.filter(Char::isDigit).take(5) },
mac = if (isEdit) mac else null, onMac = { mac = it },
canAdd = canAdd,
onAdd = { commit() },
onDismiss = onDismiss,
)
return
}
var focus by remember { mutableIntStateOf(1) } // start on Address
var editing by remember { mutableStateOf<String?>(null) } // field id being typed, or null
var kbRow by remember { mutableIntStateOf(1) }
var kbCol by remember { mutableIntStateOf(0) }
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val hazeState = remember { HazeState() }
val fields = buildList {
add(Field("name", "Name", name, "Optional — e.g. Living Room"))
add(Field("address", "Address", address, "IP or hostname"))
add(Field("port", "Port", port, "9777"))
if (isEdit) add(Field("mac", "Wake MAC", mac, "auto-filled when the host is seen"))
}
val actionIndex = fields.size // the Save/Add action sits just after the last field
fun openKeyboard(id: String) { editing = id; kbRow = 1; kbCol = 0 }
fun closeKeyboard() { editing = null }
fun editField(id: String, transform: (String) -> String) {
when (id) {
"name" -> name = transform(name)
"address" -> address = transform(address)
"port" -> port = transform(port).take(5)
"mac" -> mac = transform(mac)
}
}
fun allowed(id: String, c: Char): Boolean = when (id) {
"port" -> c.isDigit()
"address" -> c != ' '
else -> true
}
fun activateField() {
if (focus == actionIndex) {
if (canAdd) commit() else { focus = 1; openKeyboard("address") }
} else {
openKeyboard(fields[focus].id)
}
}
fun pressKey() {
val id = editing ?: return
if (kbRow < KB_ACTIONS_ROW) {
val c = KB_CHAR_ROWS[kbRow][kbCol.coerceIn(0, KB_CHAR_ROWS[kbRow].lastIndex)]
if (allowed(id, c)) editField(id) { it + c }
} else when (kbCol) {
0 -> if (allowed(id, ' ')) editField(id) { "$it " }
1 -> editField(id) { it.dropLast(1) }
else -> closeKeyboard()
}
}
BackHandler { if (editing != null) closeKeyboard() else onDismiss() }
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
if (editing == null) {
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < actionIndex) focus++
else -> {}
}
} else {
when (dir) {
NavDir.UP -> if (kbRow > 0) { kbRow--; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
NavDir.DOWN -> if (kbRow < KB_ROWS - 1) { kbRow++; kbCol = kbCol.coerceIn(0, rowCols(kbRow) - 1) }
NavDir.LEFT -> if (kbCol > 0) kbCol--
NavDir.RIGHT -> if (kbCol < rowCols(kbRow) - 1) kbCol++
}
}
},
onActivate = { if (editing == null) activateField() else pressKey() },
onTertiary = { if (editing != null) editField(editing!!) { it.dropLast(1) } },
onSecondary = { if (editing != null) closeKeyboard() },
)
val onFieldClick: (Int) -> Unit = { i -> if (focus == i) activateField() else focus = i }
val onAddClick: () -> Unit = { if (focus == actionIndex) activateField() else focus = actionIndex }
// Tappable (touch escape hatch): the legend doubles as buttons when there's no working controller.
val typeHints = listOf(
PadGlyph.hint('A', "Type") { pressKey() },
PadGlyph.hint('X', "Delete") { editing?.let { id -> editField(id) { it.dropLast(1) } } },
PadGlyph.hint('B', "Done") { closeKeyboard() },
)
val sideBySide = landscape && editing != null
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadFormBackground(Modifier.fillMaxSize())
if (sideBySide) {
// Landscape + typing: fields and keyboard SIDE BY SIDE so the field being edited stays
// visible (stacked, the keyboard covered the whole short screen). The legend is NOT put
// under the keyboard here — it floats at the same fixed bottom-left spot as everywhere.
Row(
Modifier.fillMaxSize().systemBarsPadding().padding(start = ConsoleEdgeInset, end = 20.dp, top = 8.dp, bottom = 8.dp),
horizontalArrangement = Arrangement.spacedBy(18.dp),
) {
Column(
Modifier.weight(1f).fillMaxHeight().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ConsoleHeader(title, horizontalInset = false)
fields.forEachIndexed { i, f -> FieldRow(f, focused = false, editing = editing == f.id) { onFieldClick(i) } }
AddActionRow(actionLabel, enabled = canAdd, focused = false) { onAddClick() }
Spacer(Modifier.height(64.dp)) // clear the floating legend at bottom-left
}
Column(
Modifier.weight(1.15f).fillMaxHeight().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
KeyboardGrid(kbRow, kbCol, compact = true) { r, c -> kbRow = r; kbCol = c; pressKey() }
}
}
} else {
// Portrait (or landscape not typing): the FORM SCROLLS so the Add button is never
// compressed by the keyboard; the keyboard sits below it; the legend floats (fixed).
Column(Modifier.fillMaxSize().systemBarsPadding().padding(horizontal = ConsoleEdgeInset)) {
Column(
Modifier.weight(1f).fillMaxWidth().verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
ConsoleHeader(title, horizontalInset = false)
if (editing == null && !landscape) {
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.55f),
modifier = Modifier.widthIn(max = 520.dp).padding(bottom = 8.dp),
)
}
fields.forEachIndexed { i, f -> FieldRow(f, focused = focus == i && editing == null, editing = editing == f.id) { onFieldClick(i) } }
AddActionRow(actionLabel, enabled = canAdd, focused = focus == actionIndex && editing == null) { onAddClick() }
Spacer(Modifier.height(72.dp)) // last field clears the floating legend when scrolled
}
if (editing != null) {
Spacer(Modifier.height(8.dp))
// The keyboard fills to the bottom; its bottom frame is padded so the fixed
// legend sits OVER that frame (bottom-left corner) rather than in a gap below.
KeyboardGrid(kbRow, kbCol, compact = false, bottomInset = 52.dp) { r, c -> kbRow = r; kbCol = c; pressKey() }
}
}
}
}
// Floating legend — ALWAYS at the same fixed bottom-start spot (portrait or landscape, keyboard
// open or not), so opening the keyboard never relocates it below the keys. Backdrop-blurred.
Box(
Modifier.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
if (editing != null) {
typeHints
} else {
listOf(
PadGlyph.hint('A', "Select") { activateField() },
PadGlyph.hint('B', "Cancel", onClick = onDismiss),
)
},
hazeState = hazeState,
)
}
}
}
/**
* Add-Host on a TV: real focusable text fields + the system (leanback) IME, driven by the OS. No
* custom keyboard or input probes — the native focus engine moves between fields and the Add button,
* and focusing a field pops the OS keyboard. B backs out.
*/
@Composable
private fun TvAddHostForm(
title: String,
actionLabel: String,
name: String,
onName: (String) -> Unit,
address: String,
onAddress: (String) -> Unit,
port: String,
onPort: (String) -> Unit,
mac: String?, // non-null only in edit mode
onMac: (String) -> Unit,
canAdd: Boolean,
onAdd: () -> Unit,
onDismiss: () -> Unit,
) {
BackHandler(onBack = onDismiss)
val firstFocus = remember { FocusRequester() }
Box(Modifier.fillMaxSize()) {
GamepadFormBackground(Modifier.fillMaxSize())
Column(
Modifier
.fillMaxSize()
.systemBarsPadding()
.padding(horizontal = 56.dp, vertical = 36.dp)
.widthIn(max = 720.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text(title, style = MaterialTheme.typography.headlineMedium, fontWeight = FontWeight.Bold, color = Color.White)
Text(
"Hosts on this network appear automatically — add one by address for everything else.",
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.55f),
)
OutlinedTextField(
value = name, onValueChange = onName, singleLine = true,
label = { Text("Name (optional)") },
modifier = Modifier.fillMaxWidth().focusRequester(firstFocus),
)
OutlinedTextField(
value = address, onValueChange = onAddress, singleLine = true,
label = { Text("Address") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = port, onValueChange = onPort, singleLine = true,
label = { Text("Port") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
if (mac != null) {
OutlinedTextField(
value = mac, onValueChange = onMac, singleLine = true,
label = { Text("Wake-on-LAN MAC") },
placeholder = { Text("auto-filled when the host is seen") },
modifier = Modifier.fillMaxWidth(),
)
}
Button(onClick = onAdd, enabled = canAdd, modifier = Modifier.fillMaxWidth()) {
Text(actionLabel)
}
}
}
LaunchedEffect(Unit) { runCatching { firstFocus.requestFocus() } }
}
private fun rowCols(row: Int): Int = if (row < KB_ACTIONS_ROW) KB_CHAR_ROWS[row].length else 3
@Composable
private fun FieldRow(f: Field, focused: Boolean, editing: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused || editing) 1f else 0.98f, label = "fieldScale")
val shape = RoundedCornerShape(14.dp)
Row(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(if (focused || editing) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, if (editing) Color(0xB38678F5) else Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(f.label, style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.SemiBold, color = Color.White)
Spacer(Modifier.weight(1f))
Text(
f.value.ifEmpty { f.placeholder },
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
color = if (f.value.isEmpty()) Color.White.copy(alpha = 0.35f) else Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (editing) Text(" |", color = Color(0xFF8678F5))
}
}
@Composable
private fun AddActionRow(label: String, enabled: Boolean, focused: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "addScale")
val shape = RoundedCornerShape(14.dp)
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick)
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Bold,
color = if (enabled) Color(0xFF8678F5) else Color.White.copy(alpha = 0.35f),
)
}
}
@Composable
private fun KeyboardGrid(
cursorRow: Int,
cursorCol: Int,
compact: Boolean,
bottomInset: Dp = 0.dp, // empty frame at the bottom of the glass for the floating legend to sit over
onKey: (Int, Int) -> Unit,
) {
val shape = RoundedCornerShape(20.dp)
val gap = if (compact) 5.dp else 7.dp
Column(
Modifier
.fillMaxWidth()
.widthIn(max = 640.dp)
.clip(shape)
.background(Color(0x1FFFFFFF))
.border(1.dp, Color.White.copy(alpha = 0.12f), shape)
.padding(start = 12.dp, end = 12.dp, top = if (compact) 8.dp else 12.dp, bottom = 12.dp + bottomInset),
verticalArrangement = Arrangement.spacedBy(gap),
) {
KB_CHAR_ROWS.forEachIndexed { r, chars ->
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
chars.forEachIndexed { c, ch ->
Keycap(ch.toString(), focused = cursorRow == r && cursorCol == c, compact = compact, modifier = Modifier.weight(1f)) { onKey(r, c) }
}
}
}
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(gap)) {
Keycap("space", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 0, compact = compact, modifier = Modifier.weight(2f)) { onKey(KB_ACTIONS_ROW, 0) }
Keycap("", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 1, compact = compact, modifier = Modifier.weight(1f)) { onKey(KB_ACTIONS_ROW, 1) }
Keycap("Done", focused = cursorRow == KB_ACTIONS_ROW && cursorCol == 2, compact = compact, modifier = Modifier.weight(1.5f)) { onKey(KB_ACTIONS_ROW, 2) }
}
}
}
@Composable
private fun Keycap(label: String, focused: Boolean, compact: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
Box(
modifier = modifier
.height(if (compact) 34.dp else 44.dp)
.clip(RoundedCornerShape(9.dp))
.background(if (focused) Color(0xFF8678F5) else Color(0x14FFFFFF))
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Text(
label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = if (focused) Color.Black else Color.White,
textAlign = TextAlign.Center,
)
}
}
@@ -0,0 +1,345 @@
package io.unom.punktfunk
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.rememberInfiniteTransition
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeEffect
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.max
import kotlin.math.sin
// The console chrome shared by the gamepad-driven screens — the Android mirror of the Apple client's
// GamepadChrome.swift: a slow-drifting violet aurora backdrop, a bottom button-glyph hint bar, and a
// connected-controller status chip. One look across every screen is what makes the console UI read
// as a coherent mode rather than a set of themed pages.
/** One drifting colour blob of the aurora field. Integer [sx]/[sy] keep the loop seamless at wrap. */
private class AuroraBlob(
val color: Color,
val baseX: Float,
val baseY: Float,
val driftX: Float,
val driftY: Float,
val sx: Int,
val sy: Int,
val phase: Float,
val radiusFrac: Float,
val alpha: Float,
)
private val auroraBlobs = listOf(
AuroraBlob(Color(0xFF877AF5), 0.30f, 0.26f, 0.16f, 0.10f, 1, 1, 0.0f, 0.62f, 0.55f), // brand violet
AuroraBlob(Color(0xFF3E33B8), 0.78f, 0.68f, 0.13f, 0.14f, 1, 2, 2.4f, 0.68f, 0.58f), // deep indigo
AuroraBlob(Color(0xFF9E4CCC), 0.16f, 0.82f, 0.12f, 0.09f, 2, 1, 4.1f, 0.52f, 0.42f), // plum
AuroraBlob(Color(0xFF3862DB), 0.72f, 0.14f, 0.10f, 0.08f, 1, 3, 1.2f, 0.48f, 0.40f), // cool blue
)
/**
* The living console backdrop: soft violet-family blobs drifting over black on slow, seamless loops,
* finished with a centre-pooling vignette and top/bottom legibility scrims. A Compose approximation
* of the Apple client's MeshGradient aurora — same brand family, same "ambience, never content" role.
*/
@Composable
fun GamepadAuroraBackground(modifier: Modifier = Modifier) {
val transition = rememberInfiniteTransition(label = "aurora")
// A full 0..2π sweep over ~96 s; integer per-blob multipliers make sin/cos continuous at the wrap
// so the field never visibly jumps when the animation restarts.
val angle by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(tween(96_000, easing = LinearEasing), RepeatMode.Restart),
label = "angle",
)
Canvas(modifier) {
drawRect(Color.Black)
val span = max(size.width, size.height)
for (b in auroraBlobs) {
val cx = (b.baseX + b.driftX * sin(angle * b.sx + b.phase)) * size.width
val cy = (b.baseY + b.driftY * cos(angle * b.sy + b.phase)) * size.height
val r = span * b.radiusFrac
drawCircle(
brush = Brush.radialGradient(
colors = listOf(b.color.copy(alpha = b.alpha), Color.Transparent),
center = Offset(cx, cy),
radius = r,
),
center = Offset(cx, cy),
radius = r,
blendMode = BlendMode.Plus,
)
}
// Cinematic vignette: pool light centre, sink the corners.
drawRect(
Brush.radialGradient(
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.44f)),
center = Offset(size.width / 2, size.height / 2),
radius = span * 0.92f,
),
)
// Top/bottom legibility scrim for the pinned title + hint bar.
drawRect(
Brush.verticalGradient(
0.0f to Color.Black.copy(alpha = 0.40f),
0.30f to Color.Black.copy(alpha = 0.05f),
0.70f to Color.Black.copy(alpha = 0.06f),
1.0f to Color.Black.copy(alpha = 0.42f),
),
)
}
}
/**
* The calm backdrop for the console FORM screens (settings, add-host) — deliberately still and quiet
* (unlike the launcher's drifting aurora), a deep indigo base with two soft brand glows so the glass
* rows have some colour + luminance to sit on. Mirrors the Apple client's GamepadFormBackground.
*/
@Composable
fun GamepadFormBackground(modifier: Modifier = Modifier) {
Canvas(modifier) {
val span = max(size.width, size.height)
drawRect(Color(0xFF131126))
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color(0xE6635AAE), Color.Transparent),
center = Offset(size.width * 0.24f, size.height * 0.12f),
radius = span * 0.7f,
),
center = Offset(size.width * 0.24f, size.height * 0.12f),
radius = span * 0.7f,
)
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color(0xBF343E96), Color.Transparent),
center = Offset(size.width * 0.82f, size.height * 0.9f),
radius = span * 0.7f,
),
center = Offset(size.width * 0.82f, size.height * 0.9f),
radius = span * 0.7f,
)
}
}
/**
* The exact inset every console screen places its floating legend at (bottom-start), so the legend
* sits in the SAME spot across Home / Settings / Add-Host and appears pinned while the content behind
* it cross-fades between screens.
*/
val ConsoleLegendInset = PaddingValues(start = 24.dp, bottom = 24.dp)
/** The shared horizontal inset for a console screen's heading (matches the legend's left edge). */
val ConsoleEdgeInset = 24.dp
/**
* The heading every console screen uses — one style, one inset, so titles line up across Home /
* Settings / Add-Host / Library. Callers place it at the top of their content (or float it, on Home).
*/
@Composable
fun ConsoleHeader(title: String, modifier: Modifier = Modifier, horizontalInset: Boolean = true) {
// `horizontalInset = false` when the caller's container already pads to ConsoleEdgeInset (e.g. a
// LazyColumn contentPadding) — so the heading lands at the SAME 24dp on every screen either way.
val h = if (horizontalInset) ConsoleEdgeInset else 0.dp
Text(
title,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = modifier.padding(start = h, end = h, top = 18.dp, bottom = 10.dp),
)
}
/**
* One glyph + label cell of a hint bar. [glyph] is the face letter; [color] its Xbox-convention hue.
* [onClick], when set, makes the cell tappable — a TOUCH escape hatch so a user without a working
* controller can still drive the console UI (and reach Settings to switch it off).
*/
class GamepadHint(
val glyph: Char,
val color: Color,
val text: String,
val onClick: (() -> Unit)? = null,
// Render as the D-pad-centre "select" button (a ring) instead of a lettered face-button disc —
// for a TV remote, which has no A/B/X/Y.
val select: Boolean = false,
// Render as the gamepad Select/View button (a small capsule).
val viewButton: Boolean = false,
)
/** Xbox-convention face-button colours, so the glyphs read at a glance across the room. */
object PadGlyph {
val A = Color(0xFF6BBE45)
val B = Color(0xFFD14B4B)
val X = Color(0xFF4B7BD1)
val Y = Color(0xFFE0B23C)
fun hint(glyph: Char, text: String, onClick: (() -> Unit)? = null) = GamepadHint(
glyph, when (glyph) { 'A' -> A; 'B' -> B; 'X' -> X; 'Y' -> Y; else -> Color(0xFF9A93C7) }, text, onClick,
)
}
/** A round face-button badge: a coloured disc with the button letter, like a controller's face. */
@Composable
fun GamepadButtonGlyph(glyph: Char, color: Color, size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(
modifier = Modifier
.size(size)
.clip(CircleShape)
.background(color),
contentAlignment = Alignment.Center,
) {
Text(
glyph.toString(),
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = (size.value * 0.52f).sp,
textAlign = TextAlign.Center,
)
}
}
/** The D-pad-centre "select" button — a green (confirm) disc with a ring; the TV-remote glyph for A. */
@Composable
private fun SelectGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(
modifier = Modifier.size(size).clip(CircleShape).background(PadGlyph.A),
contentAlignment = Alignment.Center,
) {
Box(Modifier.size(size * 0.46f).clip(CircleShape).border(2.dp, Color.White, CircleShape))
}
}
/** The remote's "Back" button — a back-arrow disc; the TV-remote glyph for B (back / cancel / done). */
@Composable
private fun BackGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
GamepadButtonGlyph('↩', PadGlyph.B, size)
}
/** The gamepad "Select / View" button — a small capsule outline, matching its physical shape. */
@Composable
private fun ViewButtonGlyph(size: androidx.compose.ui.unit.Dp = 26.dp) {
Box(Modifier.size(size), contentAlignment = Alignment.Center) {
Box(
Modifier
.size(width = size * 0.74f, height = size * 0.46f)
.clip(RoundedCornerShape(50))
.border(1.6.dp, Color.White.copy(alpha = 0.85f), RoundedCornerShape(50)),
)
}
}
/**
* The pinned controls legend every gamepad screen shows along the bottom — worn as a self-contained
* translucent pill so it floats over the aurora rather than dissolving into it.
*/
@Composable
fun GamepadHintBar(hints: List<GamepadHint>, modifier: Modifier = Modifier, hazeState: HazeState? = null) {
// On a TV D-pad remote (no A/B/X/Y), auto-swap the two universal pad glyphs every screen uses:
// A (confirm) → the select ring, B (back/cancel) → a back glyph. Screen-specific glyphs like the
// home's Up/Down handle themselves. Defaults to the gamepad look off an Activity (preview/tests).
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: true
val shape = RoundedCornerShape(50)
// With a haze source, blur the content behind the pill (real backdrop blur, API 31+; a translucent
// scrim below) + a light tint; otherwise fall back to a solid frosted fill.
val frosted = if (hazeState != null) {
modifier.clip(shape).hazeEffect(hazeState).background(Color(0x4014122A))
} else {
modifier.clip(shape).background(Color(0x8C14122A))
}
Row(
modifier = frosted
.border(1.dp, Color.White.copy(alpha = 0.14f), shape)
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(11.dp),
) {
for (h in hints) {
val cb = h.onClick
val cell = if (cb != null) {
Modifier.clip(RoundedCornerShape(50)).clickable(onClick = cb).padding(horizontal = 4.dp, vertical = 5.dp)
} else {
Modifier
}
Row(modifier = cell, verticalAlignment = Alignment.CenterVertically) {
when {
h.viewButton -> ViewButtonGlyph()
h.select || (!padIsGamepad && h.glyph == 'A') -> SelectGlyph()
!padIsGamepad && h.glyph == 'B' -> BackGlyph()
else -> GamepadButtonGlyph(h.glyph, h.color)
}
Spacer(Modifier.width(6.dp))
Text(
h.text,
style = MaterialTheme.typography.labelLarge,
color = Color.White.copy(alpha = 0.9f),
maxLines = 1,
softWrap = false, // never char-wrap a label when several hints crowd a narrow pill
)
}
}
}
}
/** "Which pad is driving this UI" — a quiet chip in the console top bar with the controller's name. */
@Composable
fun ControllerStatusChip(name: String, modifier: Modifier = Modifier) {
Row(
modifier = modifier
.clip(RoundedCornerShape(50))
.background(Color.White.copy(alpha = 0.08f))
.padding(horizontal = 12.dp, vertical = 7.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
Icons.Filled.SportsEsports,
contentDescription = null,
tint = Color.White.copy(alpha = 0.75f),
modifier = Modifier.size(16.dp),
)
Spacer(Modifier.width(7.dp))
Text(
name,
style = MaterialTheme.typography.labelMedium,
color = Color.White.copy(alpha = 0.75f),
maxLines = 1,
)
}
}
@@ -0,0 +1,357 @@
package io.unom.punktfunk
import android.os.Build
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// Console-styled trust/pairing dialogs — the controller-navigable counterparts of the touch
// AlertDialogs in ConnectDialogs.kt, shown while the gamepad UI is active. A dark glass card over a
// scrim with focusable action buttons: D-pad left/right moves the focus, A activates it, B dismisses.
/** One dialog action button. */
class DialogAction(
val label: String,
val primary: Boolean = false,
val enabled: Boolean = true,
val onClick: () -> Unit,
)
/**
* The shared console-dialog scaffold: scrim + glass card with a title, [body], and a row of focusable
* [actions]. Owns its own controller nav (the presenting carousel drops its probes while a dialog is
* up, via ConnectScreen's `navActive`). B → [onDismiss].
*/
@Composable
fun GamepadDialog(
title: String,
onDismiss: () -> Unit,
actions: List<DialogAction>,
body: @Composable ColumnScope.() -> Unit,
) {
// Focus the primary action; buttons are stacked full-width, navigated up/down (fits long labels
// like "Request access" without the cramped-row wrapping a horizontal layout caused).
var focus by remember { mutableIntStateOf(actions.indexOfFirst { it.primary }.coerceAtLeast(0)) }
BackHandler(onBack = onDismiss)
GamepadNavEffect2D(
active = true,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < actions.lastIndex) focus++
else -> {}
}
},
onActivate = { actions.getOrNull(focus)?.takeIf { it.enabled }?.onClick?.invoke() },
)
// Cap the card to most of the screen and let the BODY scroll — in a short landscape window the
// title + body + buttons would otherwise overflow and compress/clip the bottom button.
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(
Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)),
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(24.dp)
.widthIn(max = 520.dp)
.heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.padding(28.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
Text(title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
// The body scrolls; the title above and the buttons below stay pinned + always visible.
Column(
Modifier.weight(1f, fill = false).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
body()
}
Spacer(Modifier.size(4.dp))
actions.forEachIndexed { i, a ->
DialogButton(a.label, focused = i == focus, primary = a.primary, enabled = a.enabled, onClick = a.onClick)
}
}
}
}
@Composable
private fun DialogButton(label: String, focused: Boolean, primary: Boolean, enabled: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1.02f else 1f, label = "btnScale")
val shape = RoundedCornerShape(14.dp)
val bg = when {
focused -> Color(0xFF6656F2)
primary -> Color(0x336656F2)
else -> Color(0x14FFFFFF)
}
val fg = when {
!enabled -> Color.White.copy(alpha = 0.35f)
focused -> Color.White
primary -> Color(0xFF8678F5)
else -> Color.White.copy(alpha = 0.85f)
}
Box(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(bg)
.border(1.dp, Color.White.copy(alpha = if (focused) 0.3f else 0.08f), shape)
.clickable(
enabled = enabled,
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 20.dp, vertical = 13.dp),
contentAlignment = Alignment.Center,
) {
Text(label, style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, color = fg, maxLines = 1)
}
}
/** Body text helper — a dimmed paragraph. */
@Composable
private fun DialogText(text: String) {
Text(text, style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f))
}
/**
* Console host options for a saved tile — Wake (offered only when offline + a MAC is known), Edit,
* Forget. Reached by pressing Up on a focused saved host in the carousel; the console counterpart of
* the touch host card's overflow menu.
*/
@Composable
fun GamepadHostOptionsDialog(
hostName: String,
canWake: Boolean,
onWake: () -> Unit,
onLibrary: (() -> Unit)?, // non-null when the game library is enabled → reachable without Y
onEdit: () -> Unit,
onForget: () -> Unit,
onDismiss: () -> Unit,
) {
GamepadDialog(
title = hostName,
onDismiss = onDismiss,
actions = buildList {
if (onLibrary != null) add(DialogAction("Library", primary = true, onClick = onLibrary))
if (canWake) add(DialogAction("Wake host", onClick = onWake))
add(DialogAction("Edit…", primary = onLibrary == null, onClick = onEdit))
add(DialogAction("Forget", onClick = onForget))
add(DialogAction("Cancel", onClick = onDismiss))
},
) {
DialogText("Manage this saved host.")
}
}
@Composable
fun GamepadTrustNewDialog(pt: PendingTrust, onTrust: () -> Unit, onPairInstead: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Trust this host?",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Pair with PIN", onClick = onPairInstead),
DialogAction("Trust (TOFU)", primary = true, onClick = onTrust),
),
) {
DialogText("First connection to ${pt.host}:${pt.port}.")
pt.advertisedFp?.let { DialogText("Fingerprint ${it.take(16)}") }
DialogText(
"This host allows trust-on-first-use, but that can't tell an impostor from the real host. " +
"Pairing with a PIN is stronger — it proves both sides.",
)
}
}
@Composable
fun GamepadFingerprintChangedDialog(pt: PendingTrust, onRepair: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Host identity changed",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Re-pair", primary = true, onClick = onRepair),
),
) {
DialogText(
"The pinned fingerprint for ${pt.host} no longer matches what it now advertises. This can " +
"mean a host reinstall — or an impostor. Re-pair with the host's PIN to continue.",
)
}
}
@Composable
fun GamepadRequestAccessDialog(pt: PendingTrust, onRequestAccess: () -> Unit, onUsePin: () -> Unit, onDismiss: () -> Unit) {
GamepadDialog(
title = "Pairing required",
onDismiss = onDismiss,
actions = listOf(
DialogAction("Cancel", onClick = onDismiss),
DialogAction("Use a PIN", onClick = onUsePin),
DialogAction("Request access", primary = true, onClick = onRequestAccess),
),
) {
DialogText("${pt.host}:${pt.port} requires pairing before it will stream.")
DialogText(
"Request access and approve this device in the host's console (or web UI) — no PIN needed. " +
"Or pair with the 4-digit PIN the host displays.",
)
}
}
@Composable
fun GamepadAwaitingApprovalDialog(hostLabel: String, onCancel: () -> Unit) {
GamepadDialog(
title = "Waiting for approval",
onDismiss = onCancel,
actions = listOf(DialogAction("Cancel", primary = true, onClick = onCancel)),
) {
val deviceName = Build.MODEL ?: "this device"
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp, color = Color.White)
Text("Approve this device on $hostLabel.", color = Color.White)
}
DialogText(
"Open the host's console (or web UI) and approve “$deviceName”. It connects automatically " +
"once you approve — no PIN needed.",
)
}
}
/**
* Console PIN pairing: four digit slots set with the D-pad (left/right selects a slot, up/down changes
* 09), then Pair. Runs [NativeBridge.nativePair] off the UI thread; on success hands the verified
* fingerprint to [onPaired]. No text keyboard needed — a PIN is four digits.
*/
@Composable
fun GamepadPairPinDialog(pt: PendingTrust, identity: ClientIdentity?, onPaired: (String) -> Unit, onDismiss: () -> Unit) {
val scope = rememberCoroutineScope()
val digits = remember(pt) { mutableStateListOf(0, 0, 0, 0) }
var slot by remember(pt) { mutableIntStateOf(0) } // 0..3 = digit slots, 4 = Pair button
var pairing by remember(pt) { mutableStateOf(false) }
var err by remember(pt) { mutableStateOf<String?>(null) }
val name = remember { Build.MODEL ?: "Android" }
fun pair() {
val id = identity ?: return
pairing = true
err = null
val pin = digits.joinToString("")
scope.launch {
val fp = withContext(Dispatchers.IO) {
NativeBridge.nativePair(pt.host, pt.port, id.certPem, id.privateKeyPem, pin, name)
}
pairing = false
if (fp.isNotEmpty()) onPaired(fp) else err = "Pairing failed — wrong PIN, or the host isn't armed."
}
}
BackHandler(onBack = { if (!pairing) onDismiss() })
GamepadNavEffect2D(
active = !pairing,
onDirection = { dir ->
when (dir) {
NavDir.LEFT -> if (slot > 0) slot--
NavDir.RIGHT -> if (slot < 4) slot++
NavDir.UP -> if (slot < 4) digits[slot] = (digits[slot] + 1) % 10
NavDir.DOWN -> if (slot < 4) digits[slot] = (digits[slot] + 9) % 10
}
},
onActivate = { if (slot == 4 && identity != null) pair() },
)
val maxCardHeight = (LocalConfiguration.current.screenHeightDp * 0.92f).dp
Box(Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.62f)), contentAlignment = Alignment.Center) {
Column(
Modifier.padding(24.dp).widthIn(max = 460.dp).heightIn(max = maxCardHeight)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xF01A1730)).border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(24.dp))
.verticalScroll(rememberScrollState())
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(18.dp),
) {
Text("Pair with PIN", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold, color = Color.White)
Text(
"Enter the 4-digit PIN shown on the host — D-pad ↑↓ sets a digit, ←→ moves.",
style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.7f), textAlign = TextAlign.Center,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
repeat(4) { i -> PinSlot(digits[i], focused = slot == i && !pairing) }
}
err?.let { Text(it, color = Color(0xFFE0736F), style = MaterialTheme.typography.bodyMedium) }
DialogButton(
label = if (pairing) "Pairing…" else "Pair",
focused = slot == 4 && !pairing,
primary = true,
enabled = !pairing && identity != null,
onClick = { if (identity != null) pair() },
)
}
}
}
@Composable
private fun PinSlot(value: Int, focused: Boolean) {
val shape = RoundedCornerShape(12.dp)
Box(
Modifier.size(54.dp, 66.dp).clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(if (focused) 2.dp else 1.dp, if (focused) Color(0xFF8678F5) else Color.White.copy(alpha = 0.1f), shape),
contentAlignment = Alignment.Center,
) {
Text(value.toString(), fontSize = 30.sp, fontWeight = FontWeight.Bold, color = Color.White, fontFamily = FontFamily.Monospace)
}
}
@@ -0,0 +1,328 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.BlurredEdgeTreatment
import androidx.compose.ui.draw.blur
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import io.unom.punktfunk.kit.security.KnownHost
import kotlin.math.absoluteValue
import kotlinx.coroutines.launch
// The gamepad-driven home — the Android mirror of the Apple client's GamepadHomeView: a distinct,
// "10-foot" console-style host launcher shown INSTEAD of the touch grid while the console UI is
// active. A center-snapping carousel of hosts (saved first, then discovered, then a trailing Add
// Host tile), driven from the couch: A connects, X opens Settings, Y opens a saved host's library.
/** One navigable launcher tile — a saved host, a discovered-but-unsaved host, or the Add Host action. */
class HomeTile(
val id: String,
val title: String,
val subtitle: String,
val filled: Boolean = false, // saved (solid monogram) vs discovered / action (tinted outline)
val online: Boolean = false, // advertising on the LAN right now
val paired: Boolean = false, // pinned identity (shows a lock)
val connecting: Boolean = false,
val isAdd: Boolean = false, // the trailing Add Host tile (plus icon, not a monogram)
val knownHost: KnownHost? = null, // set for saved hosts → enables the library (Y)
val activate: () -> Unit,
) {
// Any SAVED host offers the library (matches Apple) — the fetch itself returns a clear "pair
// first" message if the host hasn't authorized this device for its management API.
val hasLibrary: Boolean get() = knownHost != null
}
/**
* The console home. [tiles] is rebuilt by the caller from the live host stores; [onActivate] runs a
* tile's action, [onOpenLibrary]/[onOpenSettings] are the Y/X actions. Fully driven by D-pad / stick
* / face buttons (MainActivity already maps a pad's A→center, B→back, sticks→D-pad) and by touch.
*/
@Composable
fun GamepadHome(
tiles: List<HomeTile>,
libraryEnabled: Boolean,
controllerName: String?,
// False while a sheet/dialog is on top → the carousel stops consuming the pad so the overlay
// can be driven instead.
navActive: Boolean,
onActivate: (HomeTile) -> Unit,
onOpenLibrary: (HomeTile) -> Unit,
onOpenSettings: () -> Unit,
// Up on a saved host opens its options (Wake / Edit / Forget). Only saved tiles carry a knownHost.
onOptions: (HomeTile) -> Unit = {},
) {
// Equal inset for the pinned title + hint bar, measured from the safe-area edges (so the legend
// sits the same distance from the left and the bottom).
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
val pagerState = rememberPagerState(pageCount = { tiles.size })
val scope = rememberCoroutineScope()
// navTarget is the navigation authority — a controller move steps THIS, and the pager is pointed
// at it, so a fast repeat coalesces to the latest target instead of reading a lagging currentPage
// mid-animation (which is what let a flick overshoot by two).
var navTarget by remember { mutableStateOf(0) }
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
val current = tiles.getOrNull(navTarget)
GamepadNavEffect(
active = navActive && tiles.isNotEmpty(),
onMove = { dir ->
val target = (navTarget + dir).coerceIn(0, tiles.lastIndex)
if (target != navTarget) {
navTarget = target
scope.launch { pagerState.animateScrollToPage(target) }
}
},
onActivate = { tiles.getOrNull(navTarget)?.let(onActivate) }, // A / D-pad-center → Connect
onSecondary = { // Y (gamepad) → Library
tiles.getOrNull(navTarget)?.takeIf { libraryEnabled && it.hasLibrary }?.let(onOpenLibrary)
},
onTertiary = onOpenSettings, // X (gamepad) → Settings
// A TV remote has no A/B/X/Y: Up → Settings, Down → a saved host's Options (Wake / Library /
// Edit / Forget). A gamepad instead opens Options on its Select/View button.
onUp = onOpenSettings,
onDown = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
onOptions = { tiles.getOrNull(navTarget)?.takeIf { it.knownHost != null }?.let(onOptions) },
)
// The legend follows the LAST-USED input: a real gamepad shows its A/X/Y face buttons + the
// Select/View button for Options; a TV D-pad remote (no face buttons) shows a select ring + Up
// (Settings) / Down (Options) arrows, with Library folded into Options. Input is universal either
// way. Each hint is also TAPPABLE (touch hatch).
val padIsGamepad = (LocalContext.current as? MainActivity)?.lastPadIsGamepad ?: false
val connectLabel = if (current?.isAdd == true) "Add Host" else "Connect"
val connectAction: () -> Unit = { tiles.getOrNull(navTarget)?.let(onActivate) }
val optionsAction: () -> Unit = { current?.let(onOptions) }
val arrowTint = Color(0xFF9A93C7)
val hints = buildList {
if (padIsGamepad) {
add(PadGlyph.hint('A', connectLabel, onClick = connectAction))
if (libraryEnabled && current?.hasLibrary == true) add(PadGlyph.hint('Y', "Library") {
tiles.getOrNull(navTarget)?.takeIf { it.hasLibrary }?.let(onOpenLibrary)
})
add(PadGlyph.hint('X', "Settings", onClick = onOpenSettings))
// The pad's Select/View button (drawn as its capsule glyph) opens host options.
if (current?.knownHost != null) add(GamepadHint(' ', arrowTint, "Options", onClick = optionsAction, viewButton = true))
} else {
add(GamepadHint(' ', PadGlyph.A, connectLabel, onClick = connectAction, select = true))
add(GamepadHint('↑', arrowTint, "Settings", onClick = { onOpenSettings() }))
if (current?.knownHost != null) add(GamepadHint('↓', arrowTint, "Options", onClick = optionsAction))
}
}
val hazeState = remember { HazeState() }
Box(Modifier.fillMaxSize()) {
// The whole backdrop (aurora + carousel) is the haze source, so the floating legend can blur
// whatever scrolls under it.
BoxWithConstraints(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
// Carousel centred on the FULL screen — the title + legend FLOAT over it (below), so they
// no longer push the cards below the true centre.
val cardWidth = (maxWidth * 0.82f).coerceAtMost(360.dp)
val cardHeight = (maxHeight * 0.56f).coerceAtMost(216.dp)
val sidePad = ((maxWidth - cardWidth) / 2).coerceAtLeast(0.dp)
Box(Modifier.fillMaxSize().systemBarsPadding()) {
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(cardWidth),
contentPadding = PaddingValues(horizontal = sidePad),
pageSpacing = 22.dp,
modifier = Modifier.fillMaxSize(),
verticalAlignment = Alignment.CenterVertically,
) { page ->
val tile = tiles[page]
// Real distance-from-centered (page + fractional drag), so the pop tracks the
// live scroll: centered tile at full scale/brightness, neighbours recede + blur.
val offset = ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction)
.absoluteValue.coerceIn(0f, 1f)
GamepadHostTile(
tile = tile,
modifier = Modifier
.graphicsLayer {
val s = lerp(1f, 0.86f, offset)
scaleX = s
scaleY = s
alpha = lerp(1f, 0.5f, offset)
}
// Unbounded so the depth blur isn't hard-clipped at the card's rectangle
// (the cut-off edge). No-op below API 31; a soft blur above.
.blur(radius = (offset * 12f).dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
.height(cardHeight)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
) {
if (page == navTarget) {
onActivate(tile)
} else {
navTarget = page
scope.launch { pagerState.animateScrollToPage(page) }
}
},
)
}
}
}
// Title floats over the top (out of the carousel's layout, so the cards stay centred). Uses
// the shared ConsoleHeader so it lines up with every other screen's heading.
Row(
Modifier.align(Alignment.TopStart).fillMaxWidth().systemBarsPadding()
.padding(end = ConsoleEdgeInset),
verticalAlignment = Alignment.CenterVertically,
) {
ConsoleHeader("Select a Host", modifier = Modifier.weight(1f))
if (controllerName != null) ControllerStatusChip(controllerName)
}
// Legend floats bottom-start with a real backdrop blur of the content behind it. In LANDSCAPE
// it ignores the safe area (the nav-bar inset made the bottom gap look oversized).
Box(
Modifier
.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
GamepadHintBar(hints, hazeState = hazeState)
}
}
}
/** One dark-glass landscape console tile — bigger and bolder than the touch grid's HostCard. */
@Composable
private fun GamepadHostTile(tile: HomeTile, modifier: Modifier = Modifier) {
val shape = RoundedCornerShape(26.dp)
val wash = if (tile.filled) {
Brush.verticalGradient(listOf(Color(0x336656F2), Color(0x14100C2A)))
} else {
Brush.verticalGradient(listOf(Color(0x1AFFFFFF), Color(0x0DFFFFFF)))
}
Column(
modifier = modifier
.fillMaxWidth()
.clip(shape)
.background(wash)
.border(1.dp, Color.White.copy(alpha = 0.16f), shape)
.padding(22.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
MonogramBadge(tile)
Spacer(Modifier.weight(1f))
Row(verticalAlignment = Alignment.CenterVertically) {
if (tile.paired) {
Icon(
Icons.Filled.Lock,
contentDescription = "Paired",
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.padding(end = 6.dp).size(15.dp),
)
}
if (tile.online) {
Box(
Modifier.size(10.dp).clip(androidx.compose.foundation.shape.CircleShape)
.background(Color(0xFF3CD070)),
)
}
}
}
Spacer(Modifier.weight(1f))
Text(
tile.title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
tile.subtitle,
style = MaterialTheme.typography.bodyMedium,
color = Color.White.copy(alpha = 0.55f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
@Composable
private fun MonogramBadge(tile: HomeTile) {
val shape = RoundedCornerShape(15.dp)
val fill = if (tile.filled) {
Brush.verticalGradient(listOf(Color(0xFF6656F2), Color(0xFF8678F5)))
} else {
Brush.verticalGradient(listOf(Color(0x296656F2), Color(0x296656F2)))
}
Box(
modifier = Modifier.size(52.dp).clip(shape).background(fill),
contentAlignment = Alignment.Center,
) {
when {
tile.connecting -> CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
color = Color.White,
)
tile.isAdd -> Icon(
Icons.Filled.Add,
contentDescription = null,
tint = if (tile.filled) Color.White else Color(0xFF8678F5),
)
else -> Text(
tile.title.trim().firstOrNull()?.uppercaseChar()?.toString() ?: "",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = if (tile.filled) Color.White else Color(0xFF8678F5),
)
}
}
}
@@ -0,0 +1,257 @@
package io.unom.punktfunk
import android.os.SystemClock
import android.view.InputDevice
import android.view.KeyEvent
import android.view.MotionEvent
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import kotlin.math.abs
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
// Controller navigation for the console carousels (host launcher + library coverflow). It taps the
// SAME MainActivity input probes the Controllers debug screen uses (padMotionProbe / padKeyProbe) so
// it sees the raw analog stick and consumes it BEFORE MainActivity's stick→D-pad focus synthesis —
// which is what made carousel scrolling feel wrong: that path is edge-only (no hold-to-repeat, so a
// held stick did nothing) and a flick could cross the threshold twice (double-move). Here the left
// stick drives discrete moves with hysteresis (fire once when it crosses HIGH; re-arm only after it
// falls back under LOW → a flick is exactly one move) and auto-repeat while held. The caller coalesces
// the moves against a target index so a fast repeat walks smoothly instead of overshooting.
private const val STICK_HIGH = 0.6f // cross this to commit a move
private const val STICK_LOW = 0.3f // fall back under this to re-arm (hysteresis)
private const val INITIAL_DELAY_MS = 420L // hold this long before the first auto-repeat
private const val REPEAT_MS = 150L // then repeat this often while held
private class NavInputState {
@Volatile var stickX = 0f
@Volatile var stickY = 0f
@Volatile var hatX = 0f
@Volatile var hatY = 0f
@Volatile var dpadX = 0
@Volatile var dpadY = 0
fun reset() { stickX = 0f; stickY = 0f; hatX = 0f; hatY = 0f; dpadX = 0; dpadY = 0 }
}
/** A committed navigation direction from the stick / D-pad / HAT. */
enum class NavDir { UP, DOWN, LEFT, RIGHT }
/**
* Installs controller navigation for a console screen while [active]. [onMove] gets -1 (left) / +1
* (right) for each committed step; [onActivate] is A / D-pad-center / Enter, [onTertiary] is X,
* [onSecondary] is Y. B and the shoulders fall through to MainActivity (B → its BACK remap → the
* screen's BackHandler). [active] is set false while a sheet/dialog is on top so the carousel stops
* consuming the pad and the overlay can be navigated.
*/
@Composable
fun GamepadNavEffect(
active: Boolean,
onMove: (Int) -> Unit,
onActivate: () -> Unit,
onSecondary: () -> Unit = {},
onTertiary: () -> Unit = {},
// D-pad Up (the carousel is horizontal) → e.g. Settings, since a TV remote has no X face button.
onUp: () -> Unit = {},
onDown: () -> Unit = {},
// Context/options menu — fired by the gamepad Select/View button OR a long-press of the select/OK
// button (the Android-TV context-menu convention). A short OK press is [onActivate].
onOptions: () -> Unit = {},
) {
val activity = LocalContext.current as? MainActivity ?: return
val state = remember { NavInputState() }
// The effects below are keyed on `active` only (they must NOT restart on every recomposition), so
// they'd otherwise capture the FIRST callbacks — closing over a stale `tiles` (fewer hosts than are
// discovered later, which clamped navigation to that old count). rememberUpdatedState keeps the
// long-lived coroutine/probes pointed at the CURRENT callbacks.
val currentOnMove by rememberUpdatedState(onMove)
val currentOnActivate by rememberUpdatedState(onActivate)
val currentOnSecondary by rememberUpdatedState(onSecondary)
val currentOnTertiary by rememberUpdatedState(onTertiary)
val currentOnUp by rememberUpdatedState(onUp)
val currentOnDown by rememberUpdatedState(onDown)
val currentOnOptions by rememberUpdatedState(onOptions)
DisposableEffect(active) {
// Stable probe refs (see GamepadNavEffect2D) so onDispose only releases the slot if we still
// own it — a cross-fading-out screen mustn't null the incoming screen's probes.
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
return@probe true // consume → MainActivity's stick→D-pad synthesis stays out of it
}
false
}
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
val down = ev.action == KeyEvent.ACTION_DOWN
val edge = down && ev.repeatCount == 0
when (ev.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
// TV remote (no face buttons): Up → Settings, Down → a saved host's Options.
KeyEvent.KEYCODE_DPAD_UP -> { if (edge) currentOnUp(); true }
KeyEvent.KEYCODE_DPAD_DOWN -> { if (edge) currentOnDown(); true }
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
// The gamepad Select / View / Share button → context options (a remote uses Down).
KeyEvent.KEYCODE_BUTTON_SELECT -> { if (edge) currentOnOptions(); true }
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
else -> false // B / shoulders / etc. → MainActivity handles (B remaps to BACK)
}
}
if (active) {
activity.padMotionProbe = motionProbe
activity.padKeyProbe = keyProbe
}
onDispose {
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
state.reset()
}
}
LaunchedEffect(active) {
if (!active) return@LaunchedEffect
var committed = 0 // the direction currently held (hysteresis + repeat authority)
var fireAt = 0L // uptime at/after which the next auto-repeat may fire
while (isActive) {
val now = SystemClock.uptimeMillis()
val hat = if (state.hatX <= -0.5f) -1 else if (state.hatX >= 0.5f) 1 else 0
val dir = when {
state.dpadX != 0 -> state.dpadX
hat != 0 -> hat
else -> {
val x = state.stickX
when {
x >= STICK_HIGH -> 1
x <= -STICK_HIGH -> -1
abs(x) < STICK_LOW -> 0
else -> committed // inside the hysteresis band → hold the committed value
}
}
}
when {
dir == 0 -> committed = 0
dir != committed -> { currentOnMove(dir); committed = dir; fireAt = now + INITIAL_DELAY_MS }
now >= fireAt -> { currentOnMove(dir); fireAt = now + REPEAT_MS }
}
delay(16)
}
}
}
/**
* 2-D controller navigation for the console form screens (settings focus list, add-host, on-screen
* keyboard). Same hysteresis + hold-to-repeat as [GamepadNavEffect] but on both axes — the dominant
* stick axis (or the pressed D-pad/HAT) commits a [NavDir], and it re-arms only after the stick
* returns near centre (so a flick is one step). [onActivate] is A / center, [onTertiary] is X,
* [onSecondary] is Y. B is left to MainActivity's BACK remap → the screen's BackHandler (so B "peels
* one layer": close the keyboard, then the screen).
*/
@Composable
fun GamepadNavEffect2D(
active: Boolean,
onDirection: (NavDir) -> Unit,
onActivate: () -> Unit,
onTertiary: () -> Unit = {},
onSecondary: () -> Unit = {},
) {
val activity = LocalContext.current as? MainActivity ?: return
val state = remember { NavInputState() }
val currentOnDirection by rememberUpdatedState(onDirection)
val currentOnActivate by rememberUpdatedState(onActivate)
val currentOnTertiary by rememberUpdatedState(onTertiary)
val currentOnSecondary by rememberUpdatedState(onSecondary)
DisposableEffect(active) {
// Stable probe refs so onDispose only releases the slot if WE still own it — during a
// cross-fade both the outgoing and incoming screen are briefly composed, and the outgoing's
// teardown must not null out the incoming screen's just-installed probes.
val motionProbe: (MotionEvent) -> Boolean = probe@{ ev ->
if (ev.isFromSource(InputDevice.SOURCE_JOYSTICK) && ev.actionMasked == MotionEvent.ACTION_MOVE) {
state.stickX = ev.getAxisValue(MotionEvent.AXIS_X)
state.stickY = ev.getAxisValue(MotionEvent.AXIS_Y)
state.hatX = ev.getAxisValue(MotionEvent.AXIS_HAT_X)
state.hatY = ev.getAxisValue(MotionEvent.AXIS_HAT_Y)
return@probe true
}
false
}
val keyProbe: (KeyEvent) -> Boolean = probe@{ ev ->
val down = ev.action == KeyEvent.ACTION_DOWN
val edge = down && ev.repeatCount == 0
when (ev.keyCode) {
KeyEvent.KEYCODE_DPAD_LEFT -> { state.dpadX = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_RIGHT -> { state.dpadX = if (down) 1 else 0; true }
KeyEvent.KEYCODE_DPAD_UP -> { state.dpadY = if (down) -1 else 0; true }
KeyEvent.KEYCODE_DPAD_DOWN -> { state.dpadY = if (down) 1 else 0; true }
KeyEvent.KEYCODE_BUTTON_A, KeyEvent.KEYCODE_DPAD_CENTER,
KeyEvent.KEYCODE_ENTER, KeyEvent.KEYCODE_NUMPAD_ENTER -> { if (edge) currentOnActivate(); true }
KeyEvent.KEYCODE_BUTTON_X -> { if (edge) currentOnTertiary(); true }
KeyEvent.KEYCODE_BUTTON_Y -> { if (edge) currentOnSecondary(); true }
else -> false // B / shoulders → MainActivity (B remaps to BACK → BackHandler)
}
}
if (active) {
activity.padMotionProbe = motionProbe
activity.padKeyProbe = keyProbe
}
onDispose {
if (activity.padMotionProbe === motionProbe) activity.padMotionProbe = null
if (activity.padKeyProbe === keyProbe) activity.padKeyProbe = null
state.reset()
}
}
LaunchedEffect(active) {
if (!active) return@LaunchedEffect
var committed: NavDir? = null
var fireAt = 0L
while (isActive) {
val now = SystemClock.uptimeMillis()
val raw = resolveDir(state)
val nearCentre = state.dpadX == 0 && state.dpadY == 0 &&
abs(state.hatX) < 0.5f && abs(state.hatY) < 0.5f &&
abs(state.stickX) < STICK_LOW && abs(state.stickY) < STICK_LOW
when {
raw == null && nearCentre -> committed = null
raw == null -> { /* in the hysteresis band → hold, don't fire */ }
raw != committed -> { currentOnDirection(raw); committed = raw; fireAt = now + INITIAL_DELAY_MS }
now >= fireAt -> { currentOnDirection(raw); fireAt = now + REPEAT_MS }
}
delay(16)
}
}
}
/** The direction currently past the commit threshold (D-pad/HAT first, then the dominant stick axis). */
private fun resolveDir(s: NavInputState): NavDir? {
if (s.dpadY < 0) return NavDir.UP
if (s.dpadY > 0) return NavDir.DOWN
if (s.dpadX < 0) return NavDir.LEFT
if (s.dpadX > 0) return NavDir.RIGHT
if (s.hatY <= -0.5f) return NavDir.UP
if (s.hatY >= 0.5f) return NavDir.DOWN
if (s.hatX <= -0.5f) return NavDir.LEFT
if (s.hatX >= 0.5f) return NavDir.RIGHT
return if (abs(s.stickY) >= abs(s.stickX)) {
when {
s.stickY <= -STICK_HIGH -> NavDir.UP
s.stickY >= STICK_HIGH -> NavDir.DOWN
else -> null
}
} else {
when {
s.stickX <= -STICK_HIGH -> NavDir.LEFT
s.stickX >= STICK_HIGH -> NavDir.RIGHT
else -> null
}
}
}
@@ -0,0 +1,313 @@
package io.unom.punktfunk
import android.content.res.Configuration
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
// The gamepad-driven settings screen — the Android mirror of the Apple client's GamepadSettingsView:
// the couch-relevant subset of the touch settings restyled as a console page and fully navigable with
// a controller: up/down moves the focus bar, left/right steps the focused value, A cycles/toggles it,
// B closes. Both write the same SharedPreferences, so values round-trip with the touch settings.
private class GpRow(
val id: String,
val header: String?,
val label: String,
val value: String,
val detail: String,
val adjust: (Int) -> Boolean, // left/right; returns whether the value actually changed
val activate: () -> Unit, // A → cycle forward (wrapping) / flip
)
@Composable
fun GamepadSettingsScreen(
initial: Settings,
onChange: (Settings) -> Unit,
onBack: () -> Unit,
navActive: Boolean = true, // false while this screen is cross-fading out, so it drops the pad
) {
var s by remember { mutableStateOf(initial) }
fun update(next: Settings) { s = next; onChange(next) }
val rows = buildSettingsRows(s, ::update)
var focus by remember { mutableIntStateOf(0) }
if (focus > rows.lastIndex) focus = rows.lastIndex
val listState = rememberLazyListState()
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
BackHandler(onBack = onBack)
GamepadNavEffect2D(
active = navActive,
onDirection = { dir ->
when (dir) {
NavDir.UP -> if (focus > 0) focus--
NavDir.DOWN -> if (focus < rows.lastIndex) focus++
NavDir.LEFT -> rows.getOrNull(focus)?.adjust(-1)
NavDir.RIGHT -> rows.getOrNull(focus)?.adjust(1)
}
},
onActivate = { rows.getOrNull(focus)?.activate() },
)
// Keep the focused row on screen, but only SCROLL when it's actually off-screen — so entering the
// screen (focus on the first row) leaves the "Settings" heading visible instead of jumping past it.
// +1 accounts for the heading being item 0.
LaunchedEffect(focus) {
runCatching {
val itemIndex = focus + 1
val info = listState.layoutInfo
val item = info.visibleItemsInfo.firstOrNull { it.index == itemIndex }
val offScreen = item == null ||
item.offset < info.viewportStartOffset ||
item.offset + item.size > info.viewportEndOffset - 96 // keep clear of the floating legend
if (offScreen) listState.animateScrollToItem(itemIndex)
}
}
val hazeState = remember { HazeState() }
Box(Modifier.fillMaxSize()) {
// Everything scrolls — including the heading — so nothing is pinned. Vital in landscape,
// where a fixed title + a fixed detail/legend strip ate most of the (short) height.
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadFormBackground(Modifier.fillMaxSize())
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize().systemBarsPadding(),
contentPadding = PaddingValues(start = 24.dp, end = 24.dp, top = 8.dp, bottom = 104.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
item(key = "__title") {
ConsoleHeader("Settings", horizontalInset = false)
}
itemsIndexed(rows, key = { _, r -> r.id }) { index, row ->
SettingRowView(row, focused = index == focus, onClick = {
if (focus == index) row.activate() else focus = index
})
}
}
}
// Floating frosted legend — a real backdrop blur of the rows scrolling behind it (no dedicated
// strip). In landscape it ignores the safe area so it hugs the corner instead of the nav-bar inset.
Box(
Modifier
.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
GamepadHintBar(
listOf(
GamepadHint('↔', Color(0xFF9A93C7), "Adjust"),
// Tappable too (touch escape hatch): Change cycles the focused row, Done leaves.
PadGlyph.hint('A', "Change") { rows.getOrNull(focus)?.activate() },
PadGlyph.hint('B', "Done", onClick = onBack),
),
hazeState = hazeState,
)
}
}
}
@Composable
private fun SettingRowView(row: GpRow, focused: Boolean, onClick: () -> Unit) {
val scale by animateFloatAsState(if (focused) 1f else 0.98f, label = "rowScale")
val shape = RoundedCornerShape(14.dp)
Column {
if (row.header != null) {
Text(
row.header.uppercase(),
style = MaterialTheme.typography.labelMedium,
color = Color.White.copy(alpha = 0.45f),
letterSpacing = 1.4.sp,
modifier = Modifier.padding(start = 16.dp, top = 14.dp, bottom = 4.dp),
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.graphicsLayer { scaleX = scale; scaleY = scale }
.clip(shape)
.background(if (focused) Color(0x336656F2) else Color(0x14FFFFFF))
.border(1.dp, Color.White.copy(alpha = if (focused) 0.28f else 0.06f), shape)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClick,
)
.padding(horizontal = 16.dp, vertical = 13.dp),
) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
row.label,
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.SemiBold,
color = Color.White,
maxLines = 1,
)
Spacer(Modifier.weight(1f))
if (focused) Text(" ", color = Color.White.copy(alpha = 0.6f))
Text(
row.value,
style = MaterialTheme.typography.bodyMedium,
color = if (focused) Color.White else Color.White.copy(alpha = 0.6f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (focused) Text(" ", color = Color.White.copy(alpha = 0.6f))
}
// The focused row carries its own one-line description — no dedicated (space-eating)
// detail strip. It appears right where you're looking, and the row grows to fit.
if (focused && row.detail.isNotBlank()) {
Text(
row.detail,
style = MaterialTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.6f),
maxLines = 2,
modifier = Modifier.padding(top = 6.dp),
)
}
}
}
}
/** Build the console settings rows from the current [Settings], writing through [update]. */
private fun buildSettingsRows(s: Settings, update: (Settings) -> Unit): List<GpRow> {
fun <T> choice(
id: String, header: String?, label: String, detail: String,
options: List<Pair<T, String>>, current: T, write: (T) -> Unit,
): GpRow {
val idx = options.indexOfFirst { it.first == current }
return GpRow(
id, header, label,
value = options.getOrNull(idx)?.second ?: "",
detail = detail,
adjust = { delta ->
if (idx < 0) {
options.firstOrNull()?.let { write(it.first) } != null
} else {
val t = idx + delta
if (t in options.indices) { write(options[t].first); true } else false
}
},
activate = {
val i = if (idx < 0) 0 else (idx + 1) % options.size
options.getOrNull(i)?.let { write(it.first) }
},
)
}
fun toggle(
id: String, header: String?, label: String, detail: String,
value: Boolean, write: (Boolean) -> Unit,
): GpRow = GpRow(
id, header, label,
value = if (value) "On" else "Off",
detail = detail,
adjust = { delta -> val target = delta > 0; if (value != target) { write(target); true } else false },
activate = { write(!value) },
)
return listOf(
choice(
"resolution", "Stream", "Resolution",
"The host creates a virtual display at exactly this size — no scaling.",
RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to lbl }, s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) },
choice(
"refresh", null, "Refresh rate", "Frame rate the host renders and streams at.",
REFRESH_OPTIONS, s.hz,
) { update(s.copy(hz = it)) },
choice(
"bitrate", null, "Bitrate",
"Automatic uses the host's default. Run a speed test from the touch UI for an informed value.",
BITRATE_OPTIONS, s.bitrateKbps,
) { update(s.copy(bitrateKbps = it)) },
choice(
"compositor", null, "Compositor",
"Which compositor drives the virtual output — honored only if available on the host.",
COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.compositor,
) { update(s.copy(compositor = it)) },
choice(
"codec", "Video", "Video codec",
"A preference — the host falls back if it can't encode this one.",
CODEC_OPTIONS, s.codec,
) { update(s.copy(codec = it)) },
toggle(
"hdr", null, "10-bit HDR",
"HDR10 — engages when the host sends HDR content and this display supports it.",
s.hdrEnabled,
) { update(s.copy(hdrEnabled = it)) },
choice(
"audio", "Audio", "Audio channels", "The speaker layout requested from the host.",
AUDIO_CHANNEL_OPTIONS, s.audioChannels,
) { update(s.copy(audioChannels = it)) },
toggle(
"mic", null, "Microphone", "Send this device's microphone to the host's virtual mic.",
s.micEnabled,
) { update(s.copy(micEnabled = it)) },
choice(
"padType", "Controller", "Controller type",
"The virtual pad the host creates — Automatic matches this controller.",
GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, s.gamepad,
) { update(s.copy(gamepad = it)) },
toggle(
"hud", "Interface", "Statistics overlay",
"Show FPS, throughput and latency while streaming.",
s.statsHudEnabled,
) { update(s.copy(statsHudEnabled = it)) },
toggle(
"library", null, "Game library",
"Browse a paired host's games with Y (experimental).",
s.libraryEnabled,
) { update(s.copy(libraryEnabled = it)) },
toggle(
"gamepadUI", null, "Controller-optimized UI",
"Turn off to use the touch interface even with a controller connected.",
s.gamepadUiEnabled,
) { update(s.copy(gamepadUiEnabled = it)) },
)
}
@@ -0,0 +1,63 @@
package io.unom.punktfunk
import android.app.UiModeManager
import android.content.Context
import android.content.pm.PackageManager
import android.content.res.Configuration
import android.hardware.input.InputManager
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import io.unom.punktfunk.kit.Gamepad
/**
* Whether the controller-optimized "console" home (the host carousel + gamepad chrome) should
* replace the touch UI — the Android mirror of the Apple client's `GamepadUIEnvironment.isActive`:
* the user's [enabled] setting AND (a controller is attached OR this is a TV OR the dev [forced]
* flag). A TV counts unconditionally — its remote/gamepad is the only input, so it's always the
* console UI (as long as the setting is on).
*/
fun gamepadUiActive(enabled: Boolean, controllerConnected: Boolean, tv: Boolean, forced: Boolean): Boolean =
enabled && (controllerConnected || tv || forced)
/** True on a TV: the leanback/television feature or the TELEVISION ui-mode. */
fun isTvDevice(context: Context): Boolean {
val pm = context.packageManager
if (pm.hasSystemFeature(PackageManager.FEATURE_LEANBACK) ||
pm.hasSystemFeature(PackageManager.FEATURE_TELEVISION)
) {
return true
}
val uiMode = context.getSystemService(Context.UI_MODE_SERVICE) as? UiModeManager
return uiMode?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
}
/**
* Live "is a game controller attached" state, updated as pads connect/disconnect via
* [InputManager]'s device listener — so the home screen flips to the console UI the instant a pad is
* plugged in or paired, and back to touch when it's removed. Mirrors the reactivity the Apple client
* gets from observing `GamepadManager.shared`.
*/
@Composable
fun rememberControllerConnected(): State<Boolean> {
val context = LocalContext.current
val connected = remember { mutableStateOf(Gamepad.firstPad() != null) }
DisposableEffect(Unit) {
val im = context.getSystemService(Context.INPUT_SERVICE) as InputManager
val listener = object : InputManager.InputDeviceListener {
private fun refresh() { connected.value = Gamepad.firstPad() != null }
override fun onInputDeviceAdded(deviceId: Int) = refresh()
override fun onInputDeviceRemoved(deviceId: Int) = refresh()
override fun onInputDeviceChanged(deviceId: Int) = refresh()
}
im.registerInputDeviceListener(listener, Handler(Looper.getMainLooper()))
connected.value = Gamepad.firstPad() != null
onDispose { im.unregisterInputDeviceListener(listener) }
}
return connected
}
@@ -0,0 +1,297 @@
package io.unom.punktfunk
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PageSize
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import android.content.res.Configuration
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.zIndex
import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import kotlinx.coroutines.launch
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.ImageLoader
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.unom.punktfunk.kit.library.DEFAULT_MGMT_PORT
import io.unom.punktfunk.kit.library.GameEntry
import io.unom.punktfunk.kit.library.LibraryClient
import io.unom.punktfunk.kit.library.LibraryResult
import io.unom.punktfunk.kit.library.mtlsHttpClient
import io.unom.punktfunk.kit.security.IdentityStore
import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.obtainIdentity
import kotlin.math.PI
import kotlin.math.absoluteValue
import kotlin.math.cos
import kotlin.math.sign
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
// The host game-library browser — the Android mirror of the Apple client's LibraryCoverflowView:
// a gamepad-driven poster coverflow (centered cover flat + prominent, neighbours receding on a 3D
// Y-tilt) fetched from the host's management API over mTLS. Reached with Y from a saved host.
private sealed class LibState {
object Loading : LibState()
data class Ready(val games: List<GameEntry>, val loader: ImageLoader) : LibState()
data class Message(val text: String) : LibState() // unauthorized / empty / error
}
@Composable
fun LibraryScreen(host: KnownHost, onBack: () -> Unit, navActive: Boolean = true) {
BackHandler(onBack = onBack)
val context = LocalContext.current
val hazeState = remember { HazeState() }
val landscape = LocalConfiguration.current.orientation == Configuration.ORIENTATION_LANDSCAPE
var state by remember { mutableStateOf<LibState>(LibState.Loading) }
LaunchedEffect(host.address, host.port, host.fpHex) {
state = LibState.Loading
state = withContext(Dispatchers.IO) {
val id = runCatching { obtainIdentity(IdentityStore(context)) }.getOrNull()
?: return@withContext LibState.Message("Identity unavailable — re-pair may be required.")
when (val res = LibraryClient.fetch(
address = host.address,
mgmtPort = DEFAULT_MGMT_PORT,
certPem = id.certPem,
keyPem = id.privateKeyPem,
fpHex = host.fpHex,
)) {
is LibraryResult.Ok -> if (res.games.isEmpty()) {
LibState.Message("No games found on this host.")
} else {
val client = mtlsHttpClient(id.certPem, id.privateKeyPem, host.address, host.fpHex)
LibState.Ready(res.games, ImageLoader.Builder(context).okHttpClient(client).build())
}
is LibraryResult.Unauthorized -> LibState.Message(res.message)
is LibraryResult.Error -> LibState.Message(res.message)
}
}
}
Box(Modifier.fillMaxSize()) {
Box(Modifier.fillMaxSize().hazeSource(hazeState)) {
GamepadAuroraBackground(Modifier.fillMaxSize())
Column(Modifier.fillMaxSize().systemBarsPadding()) {
ConsoleHeader("${host.name} — Library")
Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) {
when (val s = state) {
is LibState.Loading -> LoadingState()
is LibState.Message -> MessageState(s.text)
is LibState.Ready -> Coverflow(s.games, s.loader, navActive)
}
}
}
}
// Floating legend at the shared spot — same landscape-aware inset as every other console
// screen (ignore the safe area in landscape, where the bottom edge isn't a tap target).
Box(
Modifier.align(Alignment.BottomStart)
.then(if (landscape) Modifier else Modifier.systemBarsPadding())
.padding(ConsoleLegendInset),
) {
GamepadHintBar(listOf(PadGlyph.hint('B', "Close", onClick = onBack)), hazeState = hazeState)
}
}
}
@Composable
private fun LoadingState() {
Column(horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(14.dp)) {
CircularProgressIndicator(color = Color.White)
Text("Loading library…", color = Color.White.copy(alpha = 0.7f), style = MaterialTheme.typography.bodyLarge)
}
}
@Composable
private fun MessageState(text: String) {
Text(
text,
color = Color.White.copy(alpha = 0.75f),
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp),
)
}
@Composable
private fun Coverflow(games: List<GameEntry>, loader: ImageLoader, navActive: Boolean) {
BoxWithConstraints(Modifier.fillMaxSize()) {
// Fit a 2:3 poster into the height the detail line leaves; clamp so it never dwarfs the screen.
val coverHeight = (maxHeight * 0.72f).coerceAtMost(360.dp)
val coverWidth = coverHeight * 2f / 3f
val sidePad = ((maxWidth - coverWidth) / 2).coerceAtLeast(0.dp)
val pagerState = rememberPagerState(pageCount = { games.size })
val scope = rememberCoroutineScope()
var navTarget by remember { mutableIntStateOf(0) }
LaunchedEffect(pagerState.settledPage) { navTarget = pagerState.settledPage }
val current = games.getOrNull(navTarget)
// Controller nav: the pad drives the coverflow (it wasn't captured before). Left/right steps a
// coalesced target the pager chases; A is reserved for launch (browse-only for now); B closes
// via the screen's BackHandler.
GamepadNavEffect(
active = navActive && games.isNotEmpty(),
onMove = { dir ->
val t = (navTarget + dir).coerceIn(0, games.lastIndex)
if (t != navTarget) { navTarget = t; scope.launch { pagerState.animateScrollToPage(t) } }
},
onActivate = { /* launch a title — browse-only for now */ },
)
Column(Modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
HorizontalPager(
state = pagerState,
pageSize = PageSize.Fixed(coverWidth),
contentPadding = PaddingValues(horizontal = sidePad),
pageSpacing = 0.dp, // translationX (below) does the spacing so covers sit closer
beyondViewportPageCount = 3, // render more neighbours so a denser fan is visible
modifier = Modifier.fillMaxWidth().height(coverHeight + 24.dp),
verticalAlignment = Alignment.CenterVertically,
) { page ->
val signed = (pagerState.currentPage - page) + pagerState.currentPageOffsetFraction
val d = signed.absoluteValue
Poster(
game = games[page],
loader = loader,
modifier = Modifier
.zIndex(-d) // centred cover on top, neighbours stacked behind
.width(coverWidth)
.height(coverHeight)
.graphicsLayer {
// Centre at full size; EVERY neighbour settles to one size, so an even pitch
// yields even VISUAL gaps. (A progressive shrink made the outer gaps grow —
// the "edges spread apart while the centre gets crowded" look.)
val scale = 1f - 0.28f * d.coerceAtMost(1f)
scaleX = scale
scaleY = scale
alpha = (1f - 0.26f * d).coerceAtLeast(0.15f) // depth via fade, not size
val rotDeg = signed.coerceIn(-2.5f, 2.5f) * 26f // tilt inward
rotationY = rotDeg
// Even neighbour pitch (0.8·cover) + a little extra outward push (ramped over
// the first step so scrolling stays smooth) so the CENTRE card breathes.
val base = signed * size.width * 0.2f - signed.coerceIn(-1f, 1f) * size.width * 0.14f
// Counter-balance: a rotated card projects narrower (≈cos θ), which opens its
// inner gap — pull it back toward centre by the half-width it loses so the
// gaps stay even no matter the tilt.
val halfW = size.width * scale * 0.5f
val counter = sign(signed) * halfW * (1f - cos(rotDeg * (PI.toFloat() / 180f)))
translationX = base + counter
// Lower cameraDistance = stronger perspective (CSS `perspective`); the flat
// 22 washed the tilt out. 9 makes the same angle read as real depth.
cameraDistance = 9f * density
transformOrigin = TransformOrigin(0.5f, 0.5f)
},
)
}
Column(
Modifier.fillMaxWidth().padding(top = 14.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
current?.title ?: " ",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = Color.White,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (current != null) {
Text(
if (current.isCustom) "CUSTOM" else "STEAM",
style = MaterialTheme.typography.labelMedium,
color = Color.White.copy(alpha = 0.5f),
letterSpacing = 2.sp,
)
}
}
}
}
}
/** One cover: walks the art candidates (portrait → header → hero) then a text placeholder. */
@Composable
private fun Poster(game: GameEntry, loader: ImageLoader, modifier: Modifier = Modifier) {
val candidates = game.art.posterCandidates
var idx by remember(game.id) { mutableStateOf(0) }
val shape = RoundedCornerShape(16.dp)
Box(
modifier = modifier
.clip(shape)
.background(Color(0xFF241F3D))
.border(1.dp, Color.White.copy(alpha = 0.12f), shape),
contentAlignment = Alignment.Center,
) {
if (idx < candidates.size) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current).data(candidates[idx]).build(),
imageLoader = loader,
contentDescription = game.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
onError = { idx++ }, // this candidate failed — try the next, or fall to the placeholder
)
} else {
Text(
game.title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
color = Color.White.copy(alpha = 0.75f),
textAlign = TextAlign.Center,
modifier = Modifier.padding(12.dp),
)
}
// Store badge, top-start.
Box(Modifier.fillMaxSize().padding(8.dp), contentAlignment = Alignment.TopStart) {
Text(
if (game.isCustom) "Custom" else "Steam",
style = MaterialTheme.typography.labelSmall,
color = Color.White,
modifier = Modifier
.clip(RoundedCornerShape(50))
.background(Color.Black.copy(alpha = 0.5f))
.padding(horizontal = 8.dp, vertical = 3.dp),
)
}
}
}
@@ -3,14 +3,21 @@ package io.unom.punktfunk
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
@@ -31,6 +38,13 @@ fun LicensesScreen(onBack: () -> Unit) {
context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
}.getOrDefault("Third-party notices unavailable.")
}
// The bundled brand typeface (Geist Sans) ships under the SIL Open Font License 1.1. The OFL
// requires the license travel with the font, so surface it here (mirrors the Apple client).
val fontLicense = remember {
runCatching {
context.assets.open("GEIST-OFL.txt").bufferedReader().use { it.readText() }
}.getOrNull()
}
val version = remember {
runCatching {
@Suppress("DEPRECATION")
@@ -38,29 +52,52 @@ fun LicensesScreen(onBack: () -> Unit) {
}.getOrNull()
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium)
if (version != null) {
Text(
"punktfunk $version",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(Modifier.fillMaxSize()) {
// Pinned header with a visible Back affordance (Back-button/gesture still work via BackHandler).
Row(
modifier = Modifier.fillMaxWidth().padding(start = 4.dp, end = 12.dp, top = 8.dp, bottom = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
Text("Open-source licenses", style = MaterialTheme.typography.headlineSmall)
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
if (version != null) {
Text(
"Punktfunk $version",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
"components below, each under its own license.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
notices,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
if (fontLicense != null) {
Text("Bundled font", style = MaterialTheme.typography.titleMedium)
Text(
"The Geist typeface is licensed under the SIL Open Font License 1.1.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
fontLicense,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
}
}
Text(
"punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
"components below, each under its own license.",
style = MaterialTheme.typography.bodyMedium,
)
Text(
notices,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
)
}
}
@@ -1,5 +1,6 @@
package io.unom.punktfunk
import android.os.Build
import android.os.Bundle
import android.view.InputDevice
import android.view.KeyEvent
@@ -10,6 +11,9 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Keymap
@@ -34,8 +38,30 @@ class MainActivity : ComponentActivity() {
var padKeyProbe: ((KeyEvent) -> Boolean)? = null
var padMotionProbe: ((MotionEvent) -> Boolean)? = null
/**
* Set by [StreamScreen] to its disconnect action. The emergency-exit chord (below) invokes it so a
* couch user with no keyboard/Back can always leave a stream.
*/
var requestStreamExit: (() -> Unit)? = null
/** Currently-held forwarded pad buttons (bitmask of `Gamepad.BTN_*`), for chord detection. */
private var heldPadButtons = 0
/**
* Whether the last console input came from a real gamepad (face buttons / stick) vs. a TV D-pad
* remote (which has no A/B/X/Y). The console UI reads this to show glyphs the user recognises — pad
* face buttons, or a select glyph + arrows for a remote. Compose observes it (a snapshot state).
*/
var lastPadIsGamepad by mutableStateOf(false)
private set
/** The panel's highest-refresh display mode (0 = unknown/unsupported), resolved once at startup. */
private var highRefreshModeId = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
resolveHighRefreshMode()
setConsoleHighRefreshRate(true) // the console UI wants max refresh; streaming manages its own
// Dark, transparent system bars regardless of the system theme — our UI is always dark, so
// the status/nav bars blend with our surface and get light icons. (The no-arg edge-to-edge
// picks the *system* light/dark, which left a black status bar over our dark background.)
@@ -43,13 +69,39 @@ class MainActivity : ComponentActivity() {
statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
)
// Dev escape hatch (mirrors the Apple client's PUNKTFUNK_FORCE_GAMEPAD_UI): force the console
// UI without a physical pad — `adb shell am start -n io.unom.punktfunk/.MainActivity --ez
// pf_force_gamepad_ui true`. Never set in normal use; real activation is a connected pad / TV.
val forceGamepadUi = intent?.getBooleanExtra("pf_force_gamepad_ui", false) ?: false
setContent {
PunktfunkTheme {
Surface(modifier = Modifier.fillMaxSize()) { App() }
Surface(modifier = Modifier.fillMaxSize()) { App(forceGamepadUi = forceGamepadUi) }
}
}
}
/** Resolve the panel's highest-refresh mode (same resolution) once, for [setConsoleHighRefreshRate]. */
private fun resolveHighRefreshMode() {
@Suppress("DEPRECATION")
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
highRefreshModeId = disp?.supportedModes?.maxWithOrNull(
compareBy({ it.refreshRate }, { it.physicalWidth * it.physicalHeight }),
)?.modeId ?: 0
}
/**
* Opt the CONSOLE UI into the panel's highest refresh mode. Some OEMs (Nothing OS among them) pin
* third-party apps to 60Hz unless they explicitly ask for more, which halves the smoothness of the
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] turns this OFF while streaming so
* its own `ANativeWindow_setFrameRate` (matched to the video) governs the panel instead.
*/
fun setConsoleHighRefreshRate(high: Boolean) {
if (highRefreshModeId == 0) return
window.attributes = window.attributes.apply {
preferredDisplayModeId = if (high) highRefreshModeId else 0
}
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handle = streamHandle
if (handle != 0L) {
@@ -60,9 +112,20 @@ class MainActivity : ComponentActivity() {
if (bit != 0) {
when (event.action) {
// repeatCount guard: don't re-send a held button as auto-repeat.
KeyEvent.ACTION_DOWN ->
KeyEvent.ACTION_DOWN -> {
if (event.repeatCount == 0) NativeBridge.nativeSendGamepadButton(handle, bit, true)
KeyEvent.ACTION_UP -> NativeBridge.nativeSendGamepadButton(handle, bit, false)
heldPadButtons = heldPadButtons or bit
// Emergency exit: Select + Start + L1 + R1 held together leaves the stream
// (a couch user has no keyboard/Back). Fired once per full chord.
if (heldPadButtons and STREAM_EXIT_CHORD == STREAM_EXIT_CHORD) {
heldPadButtons = 0
requestStreamExit?.let { exit -> window.decorView.post { exit() } }
}
}
KeyEvent.ACTION_UP -> {
NativeBridge.nativeSendGamepadButton(handle, bit, false)
heldPadButtons = heldPadButtons and bit.inv()
}
}
return true // consumed
}
@@ -90,18 +153,29 @@ class MainActivity : ComponentActivity() {
}
}
} else {
// Note which input the console UI is being driven by, so its glyphs match (a TV remote's
// D-pad is not from SOURCE_GAMEPAD; a pad's face buttons / D-pad are).
if (event.action == KeyEvent.ACTION_DOWN && isConsoleNavKey(event.keyCode)) {
lastPadIsGamepad = event.isFromSource(InputDevice.SOURCE_GAMEPAD)
}
// The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true }
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face
// buttons to the navigation keys the focus system understands; D-pad *keys* already
// move focus on their own, so they fall through to super untouched.
val mapped = when (event.keyCode) {
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss
else -> 0
// buttons to the navigation the focus system / back stack understand; D-pad *keys*
// already move focus on their own, so they fall through to super untouched.
when (event.keyCode) {
// B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
// BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
// onKeyUp(BACK) never calls onBackPressed() and Compose BackHandlers wouldn't fire.
KeyEvent.KEYCODE_BUTTON_B -> {
if (event.action == KeyEvent.ACTION_UP) onBackPressedDispatcher.onBackPressed()
return true
}
// A → activate the focused element (the focus system understands DPAD_CENTER).
KeyEvent.KEYCODE_BUTTON_A ->
return super.dispatchKeyEvent(KeyEvent(event.action, KeyEvent.KEYCODE_DPAD_CENTER))
}
if (mapped != 0) return super.dispatchKeyEvent(KeyEvent(event.action, mapped))
}
}
return super.dispatchKeyEvent(event)
@@ -137,6 +211,7 @@ class MainActivity : ComponentActivity() {
if (dir != lastNavDir) {
lastNavDir = dir
if (dir != 0) {
lastPadIsGamepad = true // a stick/HAT push can only come from a real gamepad
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, dir))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
return true
@@ -147,4 +222,17 @@ class MainActivity : ComponentActivity() {
}
return super.dispatchGenericMotionEvent(event)
}
/** Keys that drive the console UI — D-pad + face buttons; used to classify the last input source. */
private fun isConsoleNavKey(kc: Int): Boolean = when (kc) {
KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_DOWN, KeyEvent.KEYCODE_DPAD_LEFT,
KeyEvent.KEYCODE_DPAD_RIGHT, KeyEvent.KEYCODE_DPAD_CENTER, KeyEvent.KEYCODE_ENTER,
-> true
else -> KeyEvent.isGamepadButton(kc)
}
private companion object {
/** Emergency stream-exit chord: Select + Start + L1 + R1 held together. */
val STREAM_EXIT_CHORD = Gamepad.BTN_BACK or Gamepad.BTN_START or Gamepad.BTN_LB or Gamepad.BTN_RB
}
}
@@ -41,6 +41,19 @@ data class Settings(
* understand touch. Mirrors the Apple client's TouchInputMode.
*/
val touchMode: TouchMode = TouchMode.TRACKPAD,
/**
* Swap the whole home screen for the controller-optimized "console" UI (the host carousel +
* gamepad chrome) whenever a controller is connected — mirrors the Apple client's
* `gamepadUIEnabled`. On by default; turn it off to keep the touch UI even with a pad attached.
* A TV (leanback) is always in this mode regardless (its remote/pad is the only input).
*/
val gamepadUiEnabled: Boolean = true,
/**
* Show the experimental game-library browser (the coverflow reached with Y from a saved host).
* Fetched from the host's management API over mTLS; needs a paired host. Mirrors the Apple
* client's `libraryEnabled`.
*/
val libraryEnabled: Boolean = true,
)
/** [Settings.touchMode] values; persisted by name. */
@@ -67,6 +80,8 @@ class SettingsStore(context: Context) {
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
)
fun save(s: Settings) {
@@ -83,6 +98,8 @@ class SettingsStore(context: Context) {
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_HUD, s.statsHudEnabled)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.apply()
}
@@ -99,6 +116,8 @@ class SettingsStore(context: Context) {
const val K_MIC = "mic_enabled"
const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_LIBRARY = "library_enabled"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode"
@@ -5,44 +5,79 @@ import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.SportsEsports
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material.icons.filled.Tv
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
/**
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native"
* resolve from the device display at connect time.
* Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
* subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen
* it becomes a two-pane list-detail (the list stays on the left, the detail on the right). Edits
* persist immediately via [onChange]; [onBack] returns to the connect screen.
*/
@Composable
fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -> Unit) {
fun SettingsScreen(
initial: Settings,
onChange: (Settings) -> Unit,
onBack: () -> Unit,
) {
var s by remember { mutableStateOf(initial) }
val context = LocalContext.current
var showLicenses by remember { mutableStateOf(false) }
@@ -52,13 +87,20 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
onChange(next)
}
BackHandler(onBack = onBack)
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
val micLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> update(s.copy(micEnabled = granted)) }
val onMicChange: (Boolean) -> Unit = { on ->
when {
!on -> update(s.copy(micEnabled = false))
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
}
}
// Deep sub-screens replace the whole settings surface (they carry their own back).
if (showLicenses) {
LicensesScreen(onBack = { showLicenses = false })
return
@@ -68,160 +110,314 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
return
}
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text("Settings", style = MaterialTheme.typography.headlineMedium)
// Selected category persists across rotation (stored by name — null = the bare list on a phone).
var selectedName by rememberSaveable { mutableStateOf<String?>(null) }
val selected = selectedName?.let { n -> SettingsCategory.entries.firstOrNull { it.name == n } }
val (nw, nh, nhz) = nativeDisplayMode(context)
BoxWithConstraints(Modifier.fillMaxSize()) {
val twoPane = maxWidth >= 640.dp
// A two-column layout must never show an empty detail — land on the first category.
LaunchedEffect(twoPane) {
if (twoPane && selected == null) selectedName = SettingsCategory.Display.name
}
SettingsGroup("Display") {
SettingDropdown(
label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) ->
(w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
},
selected = s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) }
SettingDropdown(
label = "Refresh rate",
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
selected = s.hz,
) { hz -> update(s.copy(hz = hz)) }
SettingDropdown(
label = "Bitrate",
options = BITRATE_OPTIONS,
selected = s.bitrateKbps,
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
SettingDropdown(
label = "Video codec",
options = CODEC_OPTIONS,
selected = s.codec,
) { c -> update(s.copy(codec = c)) }
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
val hdrCapable = remember { displaySupportsHdr(context) }
ToggleRow(
title = "HDR",
subtitle = if (hdrCapable) {
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
} else {
"This display can't present HDR10 — streams stay SDR"
},
checked = s.hdrEnabled && hdrCapable,
enabled = hdrCapable,
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
val detail: @Composable (SettingsCategory, (() -> Unit)?) -> Unit = { cat, back ->
CategoryDetail(
category = cat,
settings = s,
onChange = ::update,
context = context,
onMicChange = onMicChange,
onOpenControllers = { showControllers = true },
onOpenLicenses = { showLicenses = true },
onBack = back,
)
}
SettingsGroup("Host") {
SettingDropdown(
label = "Compositor",
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.compositor,
) { c -> update(s.copy(compositor = c)) }
SettingDropdown(
label = "Controller type",
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.gamepad,
) { g -> update(s.copy(gamepad = g)) }
ClickableRow(
title = "Connected controllers",
subtitle = "What the app detects, with a live input test",
onClick = { showControllers = true },
)
}
SettingsGroup("Audio") {
SettingDropdown(
label = "Audio channels",
options = AUDIO_CHANNEL_OPTIONS,
selected = s.audioChannels,
) { ch -> update(s.copy(audioChannels = ch)) }
ToggleRow(
title = "Microphone",
subtitle = "Send your mic to the host's virtual microphone",
checked = s.micEnabled,
onCheckedChange = { on ->
when {
!on -> update(s.copy(micEnabled = false))
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED -> update(s.copy(micEnabled = true))
else -> micLauncher.launch(Manifest.permission.RECORD_AUDIO)
if (twoPane) {
BackHandler(onBack = onBack)
Row(Modifier.fillMaxSize()) {
CategoryList(
selected = selected,
twoPane = true,
onSelect = { selectedName = it.name },
modifier = Modifier.width(300.dp).fillMaxHeight(),
)
VerticalDivider()
Box(Modifier.weight(1f).fillMaxHeight()) {
// Cross-fade the detail pane as the selected category changes.
AnimatedContent(
targetState = selected ?: SettingsCategory.Display,
transitionSpec = { fadeIn(tween(200)) togetherWith fadeOut(tween(200)) },
label = "SettingsPane",
) { cat -> detail(cat, null) }
}
}
} else {
// Compact: the category list pushes to a full-screen detail and back, like the iOS /
// Android system settings — a horizontal slide that tracks the drill-in direction.
BackHandler { if (selected != null) selectedName = null else onBack() }
AnimatedContent(
targetState = selected,
transitionSpec = {
if (targetState != null) {
slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut()
} else {
slideInHorizontally { -it } + fadeIn() togetherWith
slideOutHorizontally { it } + fadeOut()
}
},
)
}
SettingsGroup("Touch input") {
SettingDropdown(
label = "Touch input",
options = TOUCH_MODE_OPTIONS,
selected = s.touchMode,
onSelect = { mode -> update(s.copy(touchMode = mode)) },
)
Text(
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger " +
"tap right-clicks, two fingers scroll, tap-then-drag holds the button. " +
"Direct pointer: the cursor jumps to your finger. Touch passthrough: real " +
"multi-touch reaches the host, for apps that understand touch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 6.dp),
)
}
SettingsGroup("Overlay") {
ToggleRow(
title = "Stats overlay",
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
checked = s.statsHudEnabled,
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
)
}
SettingsGroup("About") {
ClickableRow(
title = "Open-source licenses",
subtitle = "Third-party notices and credits",
onClick = { showLicenses = true },
)
label = "SettingsPush",
) { sel ->
if (sel == null) {
CategoryList(
selected = null,
twoPane = false,
onSelect = { selectedName = it.name },
modifier = Modifier.fillMaxSize(),
)
} else {
detail(sel) { selectedName = null }
}
}
}
}
}
/** A titled group of settings rendered inside an outlined card. */
/** The top-level settings groups — each opens its own subpage (list on phone, split on tablet). */
enum class SettingsCategory(val title: String, val icon: ImageVector) {
Display("Display", Icons.Filled.Tv),
Audio("Audio", Icons.Filled.VolumeUp),
Controls("Controls", Icons.Filled.SportsEsports),
Interface("Interface", Icons.Filled.Tune),
About("About", Icons.Filled.Info),
}
/** The category list — the settings root. Highlights the [selected] row when it drives a detail pane. */
@Composable
private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
private fun CategoryList(
selected: SettingsCategory?,
twoPane: Boolean,
onSelect: (SettingsCategory) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp),
"Settings",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(start = 8.dp, bottom = 12.dp),
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
SettingsCategory.entries.forEach { cat ->
val highlighted = twoPane && selected == cat
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp))
.background(if (highlighted) MaterialTheme.colorScheme.secondaryContainer else Color.Transparent)
.clickable { onSelect(cat) }
.padding(horizontal = 14.dp, vertical = 15.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
cat.icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(end = 16.dp),
)
Text(cat.title, style = MaterialTheme.typography.bodyLarge, modifier = Modifier.weight(1f))
if (!twoPane) {
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
/** One category's controls. [onBack] non-null (phone push) shows a back arrow; null (tablet pane) hides it. */
@Composable
private fun CategoryDetail(
category: SettingsCategory,
settings: Settings,
onChange: (Settings) -> Unit,
context: android.content.Context,
onMicChange: (Boolean) -> Unit,
onOpenControllers: () -> Unit,
onOpenLicenses: () -> Unit,
onBack: (() -> Unit)?,
) {
Column(
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (onBack != null) {
IconButton(onClick = onBack, modifier = Modifier.padding(end = 4.dp)) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
}
Text(category.title, style = MaterialTheme.typography.headlineMedium)
}
when (category) {
SettingsCategory.Display -> DisplaySettings(settings, onChange, context)
SettingsCategory.Audio -> AudioSettings(settings, onChange, onMicChange)
SettingsCategory.Controls -> ControlsSettings(settings, onChange, onOpenControllers)
SettingsCategory.Interface -> InterfaceSettings(settings, onChange)
SettingsCategory.About -> AboutSettings(onOpenLicenses)
}
}
}
@Composable
private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: android.content.Context) {
val (nw, nh, nhz) = nativeDisplayMode(context)
SettingsCard {
SettingDropdown(
label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) },
selected = s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) }
SettingDropdown(
label = "Refresh rate",
options = REFRESH_OPTIONS.map { (hz, lbl) -> hz to (if (hz == 0) "$lbl ($nhz Hz)" else lbl) },
selected = s.hz,
) { hz -> update(s.copy(hz = hz)) }
SettingDropdown(label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps) { kbps ->
update(s.copy(bitrateKbps = kbps))
}
SettingDropdown(label = "Video codec", options = CODEC_OPTIONS, selected = s.codec) { c ->
update(s.copy(codec = c))
}
// HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is
// disabled (and HDR is never advertised) so the host doesn't send PQ the panel mis-tone-maps.
val hdrCapable = remember { displaySupportsHdr(context) }
ToggleRow(
title = "HDR",
subtitle = if (hdrCapable) {
"Stream 10-bit HDR (BT.2020 PQ) when the host supports it"
} else {
"This display can't present HDR10 — streams stay SDR"
},
checked = s.hdrEnabled && hdrCapable,
enabled = hdrCapable,
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
)
SettingDropdown(
label = "Compositor",
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.compositor,
) { c -> update(s.copy(compositor = c)) }
}
}
@Composable
private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange: (Boolean) -> Unit) {
SettingsCard {
SettingDropdown(label = "Audio channels", options = AUDIO_CHANNEL_OPTIONS, selected = s.audioChannels) { ch ->
update(s.copy(audioChannels = ch))
}
ToggleRow(
title = "Microphone",
subtitle = "Send your mic to the host's virtual microphone",
checked = s.micEnabled,
onCheckedChange = onMicChange,
)
}
}
@Composable
private fun ControlsSettings(s: Settings, update: (Settings) -> Unit, onOpenControllers: () -> Unit) {
SettingsCard {
SettingDropdown(label = "Touch input", options = TOUCH_MODE_OPTIONS, selected = s.touchMode) { mode ->
update(s.copy(touchMode = mode))
}
Text(
"Trackpad: relative cursor like a laptop touchpad — tap to click, two-finger tap " +
"right-clicks, two fingers scroll, tap-then-drag holds the button. Direct pointer: " +
"the cursor jumps to your finger. Touch passthrough: real multi-touch reaches the " +
"host, for apps that understand touch.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
SettingsCard {
SettingDropdown(
label = "Controller type",
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.gamepad,
) { g -> update(s.copy(gamepad = g)) }
ClickableRow(
title = "Connected controllers",
subtitle = "What the app detects, with a live input test",
onClick = onOpenControllers,
)
}
}
@Composable
private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
SettingsCard {
ToggleRow(
title = "Controller-optimized UI",
subtitle = "Switch to the console home (host carousel) when a controller is connected",
checked = s.gamepadUiEnabled,
onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = on)) },
)
ToggleRow(
title = "Game library",
subtitle = "Browse a paired host's game library (press Y on a saved host)",
checked = s.libraryEnabled,
onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
)
ToggleRow(
title = "Stats overlay",
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
checked = s.statsHudEnabled,
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
)
}
}
@Composable
private fun AboutSettings(onOpenLicenses: () -> Unit) {
SettingsCard {
ClickableRow(
title = "Open-source licenses",
subtitle = "Third-party notices and credits",
onClick = onOpenLicenses,
)
}
}
/** A group of settings rendered inside an outlined card. */
@Composable
private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = content,
)
}
}
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
@Composable
private fun ToggleRow(
@@ -265,6 +461,12 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
}
}
@@ -91,6 +91,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
activity?.streamHandle = handle // route hardware keys to this session
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes
activity?.requestStreamExit = onDisconnect // Select+Start+L1+R1 chord leaves the stream
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
val feedback = GamepadFeedback(handle).also { it.start() }
onDispose {
@@ -99,6 +101,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.axisMapper?.reset() // release-all so nothing sticks on the host
activity?.axisMapper = null
activity?.streamHandle = 0L
activity?.requestStreamExit = null
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
// Release the landscape lock so the rest of the app follows the device/system again.
@@ -41,5 +41,7 @@ fun PunktfunkTheme(content: @Composable () -> Unit) {
} else {
BrandDark
}
MaterialTheme(colorScheme = scheme, content = content)
// Geist Sans across the whole type scale — the brand typeface the website and the Apple client
// already ship (see Type.kt).
MaterialTheme(colorScheme = scheme, typography = PunktfunkTypography, content = content)
}
@@ -0,0 +1,44 @@
package io.unom.punktfunk
import androidx.compose.material3.Typography
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
// Geist — the punktfunk brand typeface (the same family the website and the Apple client ship).
// Bundled as static OTF weights in res/font and applied to every Material 3 text style below, so the
// Android UI carries the brand type identically to the other clients. Geist Sans only — Geist Mono
// is intentionally not shipped (the licenses screen's technical block uses the platform monospace).
//
// Licensed under the SIL Open Font License 1.1 (see the Geist OFL entry in THIRD-PARTY-NOTICES.txt).
val Geist = FontFamily(
Font(R.font.geist_regular, FontWeight.Normal),
Font(R.font.geist_medium, FontWeight.Medium),
Font(R.font.geist_semibold, FontWeight.SemiBold),
Font(R.font.geist_bold, FontWeight.Bold),
)
/**
* The default Material 3 type scale re-based on [Geist]. Material 3's [Typography] has no
* `defaultFontFamily` shortcut (that was Material 2), so each of the 15 roles is re-emitted with the
* Geist family while keeping Material's sizes, line heights, letter spacing and per-role weights.
*/
val PunktfunkTypography: Typography = Typography().run {
Typography(
displayLarge = displayLarge.copy(fontFamily = Geist),
displayMedium = displayMedium.copy(fontFamily = Geist),
displaySmall = displaySmall.copy(fontFamily = Geist),
headlineLarge = headlineLarge.copy(fontFamily = Geist),
headlineMedium = headlineMedium.copy(fontFamily = Geist),
headlineSmall = headlineSmall.copy(fontFamily = Geist),
titleLarge = titleLarge.copy(fontFamily = Geist),
titleMedium = titleMedium.copy(fontFamily = Geist),
titleSmall = titleSmall.copy(fontFamily = Geist),
bodyLarge = bodyLarge.copy(fontFamily = Geist),
bodyMedium = bodyMedium.copy(fontFamily = Geist),
bodySmall = bodySmall.copy(fontFamily = Geist),
labelLarge = labelLarge.copy(fontFamily = Geist),
labelMedium = labelMedium.copy(fontFamily = Geist),
labelSmall = labelSmall.copy(fontFamily = Geist),
)
}
@@ -0,0 +1,125 @@
package io.unom.punktfunk
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import io.unom.punktfunk.kit.NativeBridge
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Wake a sleeping host and WAIT for it to come back before proceeding — the Android mirror of the
* Apple client's `HostWaker`.
*
* A magic packet is fire-and-forget, and a cold box can take 2060 s to POST, boot, and start
* advertising on mDNS again — far longer than a connect attempt will sit. So instead of firing one
* packet and immediately dialing (which just fails on a genuinely-asleep host), this drives a visible
* "Waking…" state: it (re-)sends the packet, polls the host's mDNS presence once a second via
* [isOnline], and on success runs [onOnline] (the real connect for a Wake-&-Connect, or nothing for
* a wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
*
* [scope] is the composition's coroutine scope (main-dispatched), so [waking] mutations and the
* [isOnline]/[onOnline] callbacks all run on the main thread; only the blocking send is off-loaded.
*/
class WakeController(private val scope: CoroutineScope) {
/** null = idle; non-null drives [WakeOverlay]. */
data class Waking(
val hostName: String,
/** Whether coming online chains into a connect (Wake & Connect) vs. just stopping. */
val connectsAfter: Boolean,
val seconds: Int = 0,
val timedOut: Boolean = false,
)
var waking by mutableStateOf<Waking?>(null)
private set
private var loop: Job? = null
/** Captured so "Try Again" replays the exact same wait. */
private var replay: (() -> Unit)? = null
/**
* Wake the host and wait for [isOnline] to go true, then run [onOnline]. [macs]/[lastIp] target
* the magic packet. No-ops straight to [onOnline] when there's nothing to wake with or the host
* is already up (a race between the caller's check and here).
*/
fun start(
hostName: String,
connectsAfter: Boolean,
macs: List<String>,
lastIp: String,
isOnline: () -> Boolean,
onOnline: () -> Unit,
) {
if (macs.isEmpty() || isOnline()) {
cancel()
onOnline()
return
}
replay = { run(hostName, connectsAfter, macs, lastIp, isOnline, onOnline) }
replay?.invoke()
}
/** Stop waiting and dismiss the overlay (B / Cancel). */
fun cancel() {
loop?.cancel()
loop = null
replay = null
waking = null
}
/** Restart the wait after a timeout (A / Try Again). */
fun retry() {
replay?.invoke()
}
private fun run(
hostName: String,
connectsAfter: Boolean,
macs: List<String>,
lastIp: String,
isOnline: () -> Boolean,
onOnline: () -> Unit,
) {
loop?.cancel()
waking = Waking(hostName = hostName, connectsAfter = connectsAfter)
loop = scope.launch {
var elapsed = 0
while (isActive) {
// Re-send periodically: a single packet can be missed, and some NICs only wake on a
// fresh packet after dropping into a deeper sleep state.
if (elapsed % RESEND_EVERY_S == 0) {
val csv = macs.joinToString(",")
launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(csv, lastIp) }
}
if (isOnline()) {
waking = null
loop = null
onOnline()
return@launch
}
if (elapsed >= TIMEOUT_S) {
waking = waking?.copy(timedOut = true)
loop = null
return@launch
}
delay(1000)
elapsed++
waking = waking?.copy(seconds = elapsed)
}
}
}
companion object {
/** How long to wait for the host to reappear before giving up (a cold boot can be a minute+). */
const val TIMEOUT_S = 90
/** Re-send the magic packet this often. */
const val RESEND_EVERY_S = 6
}
}
@@ -0,0 +1,124 @@
package io.unom.punktfunk
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Bedtime
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
/**
* The "Waking <host>…" modal shown while [WakeController] brings a sleeping host back — a spinner + a
* live elapsed counter, escalating to a retry/cancel prompt on timeout. The Android mirror of the
* Apple client's `WakeOverlay`. Rendered over BOTH the touch grid and the console home; it swallows
* input to the screen behind it, and in console mode the pad drives it (B cancels, A retries once
* timed out) while the touch buttons work for a pointer.
*/
@Composable
fun WakeOverlay(waker: WakeController, gamepadUi: Boolean) {
val w = waker.waking ?: return
BackHandler { waker.cancel() } // system Back / pad B (remapped) cancels the wait
if (gamepadUi) {
// A retries once timed out; B falls through to the BackHandler above.
GamepadNavEffect2D(
active = true,
onDirection = {},
onActivate = { if (w.timedOut) waker.retry() },
)
}
Box(
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.6f))
// Swallow taps so the home behind can't be touched while waking.
.clickable(interactionSource = remember { MutableInteractionSource() }, indication = null) {},
contentAlignment = Alignment.Center,
) {
Column(
Modifier
.padding(40.dp)
.widthIn(max = 380.dp)
.clip(RoundedCornerShape(22.dp))
.background(Color(0xF01A1730))
.border(1.dp, Color.White.copy(alpha = 0.12f), RoundedCornerShape(22.dp))
.padding(28.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(14.dp),
) {
if (w.timedOut) {
Icon(
Icons.Filled.Bedtime,
contentDescription = null,
tint = Color.White.copy(alpha = 0.85f),
modifier = Modifier.size(34.dp),
)
Text(
"${w.hostName} didn't wake",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 19.sp,
textAlign = TextAlign.Center,
)
Text(
"It may still be booting, or it's powered off / off this network.",
color = Color.White.copy(alpha = 0.6f),
fontSize = 13.sp,
textAlign = TextAlign.Center,
)
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.padding(top = 6.dp),
) {
OutlinedButton(onClick = { waker.cancel() }) { Text("Cancel") }
Button(onClick = { waker.retry() }) { Text("Try Again") }
}
} else {
CircularProgressIndicator(color = Color.White)
Text(
"Waking ${w.hostName}",
color = Color.White,
fontWeight = FontWeight.Bold,
fontSize = 19.sp,
textAlign = TextAlign.Center,
)
Text(
"Waiting for it to come online · ${w.seconds}s",
color = Color.White.copy(alpha = 0.6f),
fontSize = 13.sp,
fontFamily = FontFamily.Monospace,
)
OutlinedButton(onClick = { waker.cancel() }, modifier = Modifier.padding(top = 6.dp)) {
Text(if (w.connectsAfter) "Cancel" else "Stop Waiting")
}
}
}
}
}
@@ -59,7 +59,7 @@ fun HostCard(
enabled: Boolean,
onConnect: () -> Unit,
onForget: (() -> Unit)?,
onRename: (() -> Unit)? = null,
onEdit: (() -> Unit)? = null,
onWake: (() -> Unit)? = null,
) {
// D-pad / controller focus highlight: a clickable card is focusable, but the default state
@@ -108,7 +108,7 @@ fun HostCard(
StatusPill(status)
}
if (onForget != null || onRename != null || onWake != null) {
if (onForget != null || onEdit != null || onWake != null) {
var menu by remember { mutableStateOf(false) }
Box(modifier = Modifier.align(Alignment.TopEnd)) {
IconButton(enabled = enabled, onClick = { menu = true }) {
@@ -129,12 +129,12 @@ fun HostCard(
},
)
}
if (onRename != null) {
if (onEdit != null) {
DropdownMenuItem(
text = { Text("Rename") },
text = { Text("Edit…") },
onClick = {
menu = false
onRename()
onEdit()
},
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.
@@ -83,7 +83,7 @@ internal fun HostsScene() {
}
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
items(SAVED) { h ->
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onRename = {})
HostCard(h.name, h.address, h.status, enabled = true, onConnect = {}, onForget = {}, onEdit = {})
}
item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp))
+12 -5
View File
@@ -15,8 +15,10 @@ android {
ndkVersion = ndkVer
defaultConfig {
minSdk = 31
ndk { abiFilters += listOf("arm64-v8a", "x86_64") }
minSdk = 28 // Android 9 — reaches older TV boxes; API 31+ features are runtime-gated.
// Keep in lockstep with :app — 32-bit armeabi-v7a for the many 32-bit Google TV / Android TV
// boxes, 64-bit arm64-v8a for phones + modern TV, x86_64 for the emulator.
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
@@ -28,6 +30,9 @@ android {
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
dependencies {
// mTLS HTTPS client for the host's management API (the game-library fetch + cover-art loads).
// OkHttp lets us present the paired client cert and pin the host's self-signed cert by SHA-256.
implementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("junit:junit:4.13.2") // JVM unit test for the pure TXT parser
}
@@ -85,9 +90,11 @@ fun registerCargoNdk(taskName: String, release: Boolean) =
// find their subtools.
val cmd = mutableListOf(
"$cargoBin/cargo", "ndk",
"-t", "arm64-v8a", "-t", "x86_64",
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found.
"--platform", "31",
"-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
// Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the
// floor makes the linker reject any accidental >28 hard import (the one API-30 call we
// make, ANativeWindow_setFrameRate, is dlsym-resolved — see decode::try_set_frame_rate).
"--platform", "28",
"-o", file("src/main/jniLibs").absolutePath,
"build", "-p", "punktfunk-client-android",
)
@@ -8,6 +8,7 @@ import android.hardware.lights.LightsRequest
import android.os.Build
import android.os.CombinedVibration
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import android.view.InputDevice
@@ -16,7 +17,8 @@ import java.nio.ByteBuffer
/**
* Host→client gamepad feedback for one session (single-pad model — pad 0 only). Two daemon poll
* threads drain the blocking native pulls and render in Kotlin: rumble → the controller's
* `VibratorManager`; HID-output → lightbar / player-LED via `LightsManager` (API 33+); adaptive
* `VibratorManager` (API 31+) or its single legacy `Vibrator` on API 2830; HID-output → lightbar /
* player-LED via `LightsManager` (API 33+); adaptive
* triggers are parse-validated and logged (Android has no public adaptive-trigger API).
*
* Mirrors `nativeStartAudio`'s lifecycle: [start]/[stop] driven by the StreamScreen. [stop] flips a
@@ -40,6 +42,9 @@ class GamepadFeedback(private val handle: Long) {
private var hidoutThread: Thread? = null
private var vm: VibratorManager? = null
// API 2830 fallback: the controller's single legacy Vibrator (no per-motor VibratorManager
// until API 31). Exactly one of [vm] / [legacy] is bound; rumble degrades to one blended motor.
private var legacy: Vibrator? = null
private var vibratorIds: IntArray = IntArray(0)
private var amplitudeControlled = false
@@ -81,6 +86,7 @@ class GamepadFeedback(private val handle: Long) {
rumbleThread?.interrupt()
hidoutThread?.interrupt()
runCatching { vm?.cancel() } // drop any held rumble immediately
runCatching { legacy?.cancel() }
// Join WITHOUT a timeout. These poll threads dereference the native session handle on every
// pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out
@@ -98,6 +104,7 @@ class GamepadFeedback(private val handle: Long) {
rgbLight = null
playerLight = null
vm = null
legacy = null
vibratorIds = IntArray(0)
}
@@ -111,39 +118,65 @@ class GamepadFeedback(private val handle: Long) {
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
return
}
val m = dev.vibratorManager
val ids = m.vibratorIds
if (ids.isEmpty()) {
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
return
if (Build.VERSION.SDK_INT >= 31) {
val m = dev.vibratorManager
val ids = m.vibratorIds
if (ids.isEmpty()) {
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrators — rumble no-op")
return
}
vm = m
vibratorIds = ids
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
} else {
// API 2830: no VibratorManager — fall back to the controller's single legacy Vibrator.
@Suppress("DEPRECATION")
val v = dev.vibrator
if (!v.hasVibrator()) {
Log.i(TAG, "rumble: controller '${dev.name}' has no vibrator — rumble no-op")
return
}
legacy = v
amplitudeControlled = v.hasAmplitudeControl()
Log.i(TAG, "rumble: bound legacy vibrator amplitudeControl=$amplitudeControlled")
}
vm = m
vibratorIds = ids
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled")
}
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
private fun renderRumble(low: Int, high: Int) {
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
val m = vm ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
if (lo == 0 && hi == 0) {
m.cancel() // (0,0) = stop
val m = vm
if (m != null) {
if (lo == 0 && hi == 0) {
m.cancel() // (0,0) = stop
return
}
val combo = CombinedVibration.startParallel()
if (amplitudeControlled && vibratorIds.size >= 2) {
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
} else {
// Single motor or no amplitude control: blend both into one effect.
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
}
runCatching { m.vibrate(combo.combine()) }
return
}
val combo = CombinedVibration.startParallel()
if (amplitudeControlled && vibratorIds.size >= 2) {
// ids[0] = light/right, ids[1] = heavy/left (XInput/Moonlight convention).
if (hi != 0) combo.addVibrator(vibratorIds[0], oneShot(hi))
if (lo != 0) combo.addVibrator(vibratorIds[1], oneShot(lo))
} else {
// Single motor or no amplitude control: blend both into one effect.
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
// API 2830 legacy single-motor path: blend both motors into one effect.
val lv = legacy ?: return
if (lo == 0 && hi == 0) {
lv.cancel() // (0,0) = stop
return
}
val a = (lo * 0.8 + hi * 0.33).toInt().coerceIn(1, 255)
runCatching {
lv.vibrate(if (amplitudeControlled) oneShot(a) else oneShot(VibrationEffect.DEFAULT_AMPLITUDE))
}
runCatching { m.vibrate(combo.combine()) }
}
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
@@ -0,0 +1,195 @@
package io.unom.punktfunk.kit.library
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONArray
import org.json.JSONObject
import java.io.ByteArrayInputStream
import java.security.KeyFactory
import java.security.KeyStore
import java.security.MessageDigest
import java.security.PrivateKey
import java.security.cert.CertificateFactory
import java.security.cert.X509Certificate
import java.security.spec.PKCS8EncodedKeySpec
import java.util.Base64
import java.util.concurrent.TimeUnit
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.KeyManagerFactory
import javax.net.ssl.SSLContext
import javax.net.ssl.TrustManager
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
// Android game-library client — the mirror of the Apple client's LibraryClient.swift. Fetches a
// host's unified game library from its management REST API (`GET /api/v1/library`) over **mTLS**: the
// paired client presents its persistent cert/key (the same identity the host paired over QUIC), and
// the host's self-signed cert is pinned by SHA-256(DER). Read-only. Mirrors the GameEntry/Artwork
// schema in crates/punktfunk-host/src/library.rs.
/** The management API's default port — matches `mgmt::DEFAULT_PORT` on the host and the Apple client. */
const val DEFAULT_MGMT_PORT = 47990
/** Cover-art URLs. Steam art arrives as host-relative proxy paths, resolved to absolute by [LibraryClient]. */
data class Artwork(val portrait: String?, val header: String?, val hero: String?) {
/** Poster preference for a 2:3 tile: portrait capsule → header → hero (near-universal fallbacks). */
val posterCandidates: List<String> get() = listOfNotNull(portrait, header, hero)
}
/** One title in the unified library. [id] is store-qualified (`steam:<appid>` / `custom:<id>`). */
data class GameEntry(val id: String, val store: String, val title: String, val art: Artwork) {
val isCustom: Boolean get() = store == "custom"
}
/** Fetch outcome — three states so the UI can guide setup (the common case is "not paired yet"). */
sealed class LibraryResult {
data class Ok(val games: List<GameEntry>) : LibraryResult()
data class Unauthorized(val message: String) : LibraryResult()
data class Error(val message: String) : LibraryResult()
}
object LibraryClient {
/**
* `GET https://<address>:<mgmtPort>/api/v1/library`, authenticated by mTLS. [fpHex] is the pinned
* host-cert SHA-256 (64 hex, from the paired [io.unom.punktfunk.kit.security.KnownHost]); a blank
* value means the host was never connected/paired, so there's nothing authorized to browse.
* BLOCKING — call from a background dispatcher.
*/
fun fetch(
address: String,
mgmtPort: Int = DEFAULT_MGMT_PORT,
certPem: String,
keyPem: String,
fpHex: String,
): LibraryResult {
if (fpHex.isBlank()) {
return LibraryResult.Unauthorized(
"Connect to this host once first — the library uses the identity created on pairing to authenticate.",
)
}
val client = try {
mtlsHttpClient(certPem, keyPem, address, fpHex)
} catch (e: Exception) {
return LibraryResult.Error("Couldn't set up the secure connection: ${e.message}")
}
val base = "https://$address:$mgmtPort"
val req = Request.Builder().url("$base/api/v1/library").build()
return try {
client.newCall(req).execute().use { resp ->
when (resp.code) {
200 -> LibraryResult.Ok(parse(resp.body?.string().orEmpty(), base))
401 -> LibraryResult.Unauthorized(
"The host didn't recognize this device. Pair with the host first — it authorizes paired clients by their certificate.",
)
else -> LibraryResult.Error("The management API returned HTTP ${resp.code}.")
}
}
} catch (e: Exception) {
LibraryResult.Error(
"Couldn't reach the host's management API: ${e.message}. It binds the LAN by default, so check the host is updated and reachable.",
)
}
}
private fun parse(json: String, base: String): List<GameEntry> {
val arr = JSONArray(json)
val out = ArrayList<GameEntry>(arr.length())
for (i in 0 until arr.length()) {
val o = arr.getJSONObject(i)
val art = o.optJSONObject("art") ?: JSONObject()
out.add(
GameEntry(
id = o.optString("id"),
store = o.optString("store"),
title = o.optString("title"),
art = Artwork(
portrait = resolveArt(str(art, "portrait"), base),
header = resolveArt(str(art, "header"), base),
hero = resolveArt(str(art, "hero"), base),
),
),
)
}
return out
}
/** A present, non-null, non-blank JSON string field, else null. */
private fun str(o: JSONObject, key: String): String? =
if (o.has(key) && !o.isNull(key)) o.optString(key).ifBlank { null } else null
/** Host-relative art path (`/api/v1/library/art/...`) → absolute against the host; else unchanged. */
private fun resolveArt(s: String?, base: String): String? =
if (s != null && s.startsWith("/")) base + s else s
}
/**
* An OkHttpClient that presents the paired client cert and pins the host's self-signed cert by
* SHA-256(DER) — reused for BOTH the library fetch and the cover-art loads (so a paired client
* reaches the host's own art proxy). The pinning trust manager trusts the host by fingerprint and
* defers to normal public trust for any other origin (an external CDN URL); the hostname verifier
* accepts the pinned host (whose self-signed cert has no matching SAN) and defers otherwise.
*/
fun mtlsHttpClient(certPem: String, keyPem: String, host: String, fpHex: String): OkHttpClient {
val clientCert = CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(certPem.toByteArray())) as X509Certificate
val privateKey = parsePrivateKey(keyPem)
val keyStore = KeyStore.getInstance("PKCS12").apply {
load(null, null)
setKeyEntry("client", privateKey, CharArray(0), arrayOf(clientCert))
}
val kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm())
kmf.init(keyStore, CharArray(0))
// System default trust manager, for non-host (external CDN) origins.
val sysTmf = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
sysTmf.init(null as KeyStore?)
val sysTm = sysTmf.trustManagers.filterIsInstance<X509TrustManager>().first()
val pinned = fpHex.lowercase()
val trustManager = object : X509TrustManager {
override fun checkClientTrusted(chain: Array<X509Certificate>, authType: String) {}
override fun checkServerTrusted(chain: Array<X509Certificate>, authType: String) {
if (sha256Hex(chain[0].encoded) == pinned) return // the pinned host
sysTm.checkServerTrusted(chain, authType) // external CDN — normal public trust
}
override fun getAcceptedIssuers(): Array<X509Certificate> = sysTm.acceptedIssuers
}
val ssl = SSLContext.getInstance("TLS")
ssl.init(kmf.keyManagers, arrayOf<TrustManager>(trustManager), null)
val defaultVerifier = HttpsURLConnection.getDefaultHostnameVerifier()
val verifier = HostnameVerifier { hostname, session ->
hostname == host || defaultVerifier.verify(hostname, session)
}
return OkHttpClient.Builder()
.sslSocketFactory(ssl.socketFactory, trustManager)
.hostnameVerifier(verifier)
.connectTimeout(8, TimeUnit.SECONDS)
.readTimeout(15, TimeUnit.SECONDS)
.build()
}
/** Parse a PKCS#8 PEM private key (rcgen emits `-----BEGIN PRIVATE KEY-----`), trying EC then RSA/Ed25519. */
private fun parsePrivateKey(pem: String): PrivateKey {
val body = pem
.replace(Regex("-----BEGIN [A-Z ]*PRIVATE KEY-----"), "")
.replace(Regex("-----END [A-Z ]*PRIVATE KEY-----"), "")
.replace(Regex("\\s"), "")
val der = Base64.getDecoder().decode(body)
val spec = PKCS8EncodedKeySpec(der)
for (alg in listOf("EC", "RSA", "Ed25519")) {
try {
return KeyFactory.getInstance(alg).generatePrivate(spec)
} catch (_: Exception) {
// try the next algorithm
}
}
throw IllegalArgumentException("unsupported private-key format (not EC/RSA/Ed25519 PKCS#8)")
}
private fun sha256Hex(der: ByteArray): String =
MessageDigest.getInstance("SHA-256").digest(der).joinToString("") { "%02x".format(it) }
@@ -74,6 +74,16 @@ class KnownHostStore(context: Context) {
save(h.copy(name = newName))
}
/**
* Edit a saved host, RE-KEYING if the address or port changed (the pref key IS `address:port`, so
* a plain [save] would otherwise leave a stale record under the old key). The caller passes an
* [updated] copy that preserves `fpHex`/`paired` (and sets `mac` from the edit form).
*/
fun update(oldAddress: String, oldPort: Int, updated: KnownHost) {
if (oldAddress != updated.address || oldPort != updated.port) remove(oldAddress, oldPort)
save(updated)
}
/** All trusted hosts, name-sorted — backs the saved-hosts list. */
fun all(): List<KnownHost> =
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() }
@@ -89,4 +99,22 @@ class KnownHostStore(context: Context) {
mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
)
}.getOrNull()
companion object {
/**
* Parse a free-typed Wake-on-LAN field into normalized `aa:bb:cc:dd:ee:ff` entries (comma /
* space / newline separated). Anything that isn't six colon-separated hex octets is dropped;
* an empty result clears the host's MAC. Mirrors the Apple client's `AddHostSheet.parseMacs`.
*/
fun parseMacs(s: String): List<String> = s
.split(',', ';', ' ', '\n', '\t')
.map { it.trim().lowercase() }
.filter { m ->
// Exactly six octets, each two literal hex digits. (Not toIntOrNull(16) — that accepts
// a leading +/- sign, so "aa:bb:cc:dd:ee:-1" would wrongly pass.)
m.split(":").let { o ->
o.size == 6 && o.all { it.length == 2 && it.all { c -> c in '0'..'9' || c in 'a'..'f' } }
}
}
}
}
@@ -0,0 +1,33 @@
package io.unom.punktfunk.kit.security
import org.junit.Assert.assertEquals
import org.junit.Test
/** Unit tests for the pure MAC-parsing helper backing the host edit form. */
class KnownHostStoreTest {
@Test
fun parsesAndNormalizesSingleMac() {
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("AA:BB:CC:DD:EE:FF"))
}
@Test
fun parsesMultipleSeparators() {
val expected = listOf("aa:bb:cc:dd:ee:ff", "11:22:33:44:55:66")
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff, 11:22:33:44:55:66"))
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff 11:22:33:44:55:66"))
assertEquals(expected, KnownHostStore.parseMacs("aa:bb:cc:dd:ee:ff\n11:22:33:44:55:66"))
}
@Test
fun dropsMalformedEntries() {
// Not six octets / bad hex / wrong width are all dropped; an empty field clears the MAC.
assertEquals(emptyList<String>(), KnownHostStore.parseMacs(""))
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("not-a-mac"))
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee")) // 5 octets
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("gg:bb:cc:dd:ee:ff")) // non-hex
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aaa:bb:cc:dd:ee:ff")) // wrong width
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("aa:bb:cc:dd:ee:-1")) // signed octet
assertEquals(emptyList<String>(), KnownHostStore.parseMacs("+a:-b:+c:-d:+e:-f")) // signed octets
assertEquals(listOf("aa:bb:cc:dd:ee:ff"), KnownHostStore.parseMacs("junk, aa:bb:cc:dd:ee:ff"))
}
}
+5 -1
View File
@@ -34,7 +34,11 @@ android_logger = "0.14"
# NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode +
# audio run entirely in Rust on native threads (the "no async on the hot path" invariant).
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-31"] }
# api-level-28 matches the app's minSdk floor (Android 9). AAudio (26), AMediaCodec (21) and
# ANativeWindow_setBuffersDataSpace (28) are all ≤28; the one API-30 call we make
# (ANativeWindow_setFrameRate) is dlsym-resolved at runtime (see decode::try_set_frame_rate), not
# linked, so the .so still loads on API 28/29.
ndk = { version = "0.9", features = ["media", "audio", "nativewindow", "api-level-28"] }
# setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
libc = "0.2"
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
+35 -6
View File
@@ -12,11 +12,12 @@ use ndk::media::media_codec::{
OutputBuffer,
};
use ndk::media::media_format::MediaFormat;
use ndk::native_window::{FrameRateCompatibility, NativeWindow};
use ndk::native_window::NativeWindow;
use punktfunk_core::client::NativeClient;
use punktfunk_core::error::PunktfunkError;
use punktfunk_core::session::Frame;
use std::collections::VecDeque;
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -113,11 +114,13 @@ pub fn run(
mode.height
);
// Tell the display the stream's refresh so Android can pick a matching display mode and align
// vsync (no 60-in-120 judder on high-refresh panels). minSdk 31 ≥ API 30, so the underlying
// ANativeWindow_setFrameRate is always present; non-fatal if the platform declines.
if let Err(e) = window.set_frame_rate(mode.refresh_hz as f32, FrameRateCompatibility::Default) {
log::warn!(
"decode: set_frame_rate({} Hz) failed (non-fatal): {e}",
// vsync (no 60-in-120 judder on high-refresh panels). `ANativeWindow_setFrameRate` is NDK API 30,
// above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
// it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
// there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32) {
log::debug!(
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
mode.refresh_hz
);
}
@@ -340,6 +343,32 @@ fn boost_thread_priority() {
}
}
/// `ANativeWindow_setFrameRate` (NDK **API 30**) resolved from `libandroid.so` at runtime, so the lib
/// still loads on our API-28 floor — a hard import of a >floor symbol makes `dlopen`/`System.load`
/// fail on every API-28/29 device, even where this path is never hit. Mirrors the dlsym approach in
/// [`crate::adpf`]. Returns `true` when the platform accepted the hint; `false` on API < 30 (symbol
/// absent) or when the platform declined. `compatibility` is fixed to the DEFAULT (0) policy.
fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32) -> bool {
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
// process-lifetime handle). `dlsym` returns null when the symbol is absent (device API < 30),
// checked before transmuting the non-null pointer to its fn-pointer type. `window.ptr()` is the
// live `ANativeWindow` this `NativeWindow` owns for the call's duration.
unsafe {
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
if lib.is_null() {
return false;
}
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
if sym.is_null() {
return false; // device API < 30 — no per-surface frame-rate hint
}
let set_frame_rate = std::mem::transmute::<*mut c_void, SetFrameRateFn>(sym);
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, 0) == 0
}
}
/// Try to copy one access unit into a codec input buffer and queue it, without blocking. Returns
/// `false` only on `TryAgainLater` (no input buffer free) — the caller keeps the AU pending and
/// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
@@ -48,21 +48,21 @@
<key>com.apple.security.device.usb</key>
<true/>
<!-- Controller rumble via CoreHaptics: GCDeviceHaptics.createEngine → CHHapticEngine
(GamepadFeedback's RumbleRenderer), and AVAudioEngine playback, reach the system
audio-analytics daemon `com.apple.audioanalyticsd` over Mach. The sandbox denies that
global-name lookup unless it's whitelisted here, and the framework's own precondition
turns the denial into a HARD CRASH ("Process is sandboxed but
com.apple.security.exception.mach-lookup.global-name doesn't contain
com.apple.audioanalyticsd") the moment a controller's haptics engine starts. This
temporary exception is the documented, App-Store-acceptable way to permit exactly that
lookup — and ONLY that service (the key takes exact names, no wildcards). App Store:
declare it in App Store Connect → App Sandbox Entitlement Usage Information ("CoreHaptics
gamepad rumble contacts the system audio-analytics daemon"). -->
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key>
<array>
<string>com.apple.audioanalyticsd</string>
</array>
<!-- NO mach-lookup temporary exception here — and none is needed. Build 0.4.2 (3384) shipped a
`com.apple.security.temporary-exception.mach-lookup.global-name` = com.apple.audioanalyticsd
exception on the THEORY that CoreHaptics controller rumble (CHHapticEngine — the session
RumbleRenderer + MenuHaptics) hard-crashes under the App Sandbox without it, because the
framework reaches the audio-analytics daemon over Mach and the sandbox denies that lookup.
App Review REJECTED the exception under guideline 2.4.5(i) (review 2026-07-04). We then
tested the premise directly on macOS: a CHHapticEngine start + full-intensity rumble on a
real Xbox pad, in a genuinely ENFORCED sandbox (NSHomeDirectory redirected into the app
container) with NO exception on the codesigned binary — and it ran WITHOUT crashing, rumble
and all, even with a live AVAudioEngine stream running concurrently. CoreHaptics simply
tolerates the denied audioanalyticsd lookup (it's telemetry, not a hard precondition). So
controller rumble works fully sandboxed with none of these exceptions. Do NOT re-add one —
it will be rejected again AND it buys nothing. (DualSense rumble separately goes over raw
HID via device.usb/device.bluetooth — CoreHaptics genuinely doesn't drive Sony motors on
macOS — but that path needs no exception either; see DualSenseHID.) -->
<!-- Keychain Sharing (unchanged from the shared file): a team-scoped access group so the
punktfunk/1 client identity in the data-protection keychain is gated by the app's
@@ -52,6 +52,9 @@ struct ContentView: View {
@State private var awaitingApproval: ApprovalRequest?
@State private var speedTestTarget: StoredHost?
@State private var libraryTarget: StoredHost?
/// Wakes a sleeping host and waits for it to come back online before connecting (drives the
/// "Waking" overlay). macOS-only in practice WoL is gated off on iOS/tvOS.
@StateObject private var waker = HostWaker()
#if !os(macOS)
@State private var showSettings = false
#endif
@@ -212,12 +215,18 @@ struct ContentView: View {
}
private var home: some View {
// The "Waking" overlay rides over BOTH home UIs (and the pre-connect window is still
// `home`, so it covers the whole wakeonlineconnect sequence).
homeBase.overlay { WakeOverlay(waker: waker) }
}
@ViewBuilder private var homeBase: some View {
#if os(macOS)
Group {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget,
libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0) }, connectDiscovered: connectDiscovered)
} else {
HomeView(
@@ -225,7 +234,7 @@ struct ContentView: View {
showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle)
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
}
}
#elseif os(iOS)
@@ -233,7 +242,7 @@ struct ContentView: View {
if gamepadUIActive {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget,
libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0) }, connectDiscovered: connectDiscovered)
} else {
HomeView(
@@ -242,7 +251,7 @@ struct ContentView: View {
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings,
connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle)
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
}
}
#else
@@ -252,7 +261,7 @@ struct ContentView: View {
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings,
connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle)
onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
#endif
}
@@ -406,9 +415,37 @@ struct ContentView: View {
/// delegated-approval connect (host parks it until the operator approves).
private func startSession(
_ host: StoredHost, launchID: String? = nil,
allowTofu: Bool, requestAccess: Bool = false
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
) {
let go = {
startSessionDirect(
host, launchID: launchID, allowTofu: allowTofu,
requestAccess: requestAccess, approvalReq: approvalReq)
}
// Asleep (not advertising) and we can wake it? Fire the magic packet and WAIT for it to come
// back online a cold box takes far longer to boot than a connect will sit showing the
// "Waking" overlay meanwhile. Then connect. Otherwise dial straight away.
if PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty, !discovery.advertises(host) {
discovery.start() // so we can observe it reappear
waker.start(
host: host, connectsAfter: true, macs: host.wakeMacs, lastIP: host.address,
isOnline: { discovery.advertises(host) }, onOnline: go)
} else {
go()
}
}
/// The actual dial reached directly when the host is awake, or from the waker once a woken
/// host is back online. `prepareWake` still runs here to LEARN/refresh the MAC now that the host
/// is advertising (and is a harmless no-op otherwise).
private func startSessionDirect(
_ host: StoredHost, launchID: String? = nil,
allowTofu: Bool, requestAccess: Bool = false, approvalReq: ApprovalRequest? = nil
) {
prepareWake(for: host)
// The delegated-approval wait prompt only makes sense once we're actually dialing set it
// here (after any wake), not before, so it never stacks under the "Waking" overlay.
if let approvalReq { awaitingApproval = approvalReq }
model.connect(
to: host,
width: UInt32(clamping: width), height: UInt32(clamping: height),
@@ -452,12 +489,24 @@ struct ContentView: View {
/// as paired (see the `.streaming` branch of `onChange`).
private func requestAccess(_ req: ApprovalRequest) {
guard !model.isBusy else { return }
awaitingApproval = req
// Pin the advertised certificate for a discovered host (impostor defence during the long
// wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
var host = req.host
host.pinnedSHA256 = req.advertisedFingerprint
startSession(host, allowTofu: false, requestAccess: true)
// `awaitingApproval` is set inside startSessionDirect (after any wake), so it never stacks
// under the "Waking" overlay.
startSession(host, allowTofu: false, requestAccess: true, approvalReq: req)
}
/// Explicit wake-only (the touch card's "Wake Host" menu item / a future gamepad action): fire
/// the packet and wait for the host to come online, but don't connect the user then sees it
/// go online and can connect.
private func wakeOnly(_ host: StoredHost) {
guard PunktfunkConnection.wakeOnLANAvailable, !host.wakeMacs.isEmpty else { return }
discovery.start()
waker.start(
host: host, connectsAfter: false, macs: host.wakeMacs, lastIP: host.address,
isOnline: { discovery.advertises(host) }, onOnline: {})
}
/// Picked a title in the (experimental) library: dismiss the browser and start a session that
@@ -1,67 +1,87 @@
// "+" sheet: name (optional) + address + port a card in the hosts grid. The first
// actual connection runs the trust-on-first-use fingerprint prompt.
// Add / edit a host: name (optional) + address + port + Wake-on-LAN MAC a card in the grid.
// The MAC prefills from what we already know the host's stored MAC, or the live mDNS advert's if
// it hasn't been learned yet so it's usually already correct; type/paste it for a host we've
// never seen advertise. The first actual connection still runs the trust-on-first-use prompt.
import PunktfunkKit
import SwiftUI
struct AddHostSheet: View {
@Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var address = ""
@State private var port = 9777
/// nil = add a new host; non-nil = edit this one (fields prefilled, identity/pin preserved).
let existing: StoredHost?
/// MAC(s) to offer when the host has none stored yet the live advert's, so the field is
/// prefilled the moment the host is on the network, even before a connect has learned it.
let suggestedMacs: [String]
let onSave: (StoredHost) -> Void
@State private var name: String
@State private var address: String
@State private var port: Int
@State private var mac: String
#if os(tvOS)
private enum EditField: String, Identifiable {
case name, address, port
case name, address, port, mac
var id: String { rawValue }
}
@State private var editing: EditField?
@State private var editingField: EditField?
#endif
let onAdd: (StoredHost) -> Void
private var isEditing: Bool { existing != nil }
private var actionTitle: String { isEditing ? "Save" : "Add Host" }
private var canSave: Bool { !address.trimmingCharacters(in: .whitespaces).isEmpty }
init(existing: StoredHost? = nil, suggestedMacs: [String] = [], onSave: @escaping (StoredHost) -> Void) {
self.existing = existing
self.suggestedMacs = suggestedMacs
self.onSave = onSave
_name = State(initialValue: existing?.name ?? "")
_address = State(initialValue: existing?.address ?? "")
_port = State(initialValue: Int(existing?.port ?? 9777))
let stored = existing?.macAddresses ?? []
_mac = State(initialValue: (stored.isEmpty ? suggestedMacs : stored).joined(separator: ", "))
}
var body: some View {
#if os(tvOS)
// No inline text editing on tvOS Settings-style value rows; pressing one
// raises the SYSTEM fullscreen keyboard (TVTextEntry).
VStack(spacing: 24) {
TVFieldRow(
label: "Name", value: name, placeholder: "Optional"
) { editing = .name }
TVFieldRow(
label: "Address", value: address, placeholder: "IP or hostname"
) { editing = .address }
TVFieldRow(
label: "Port", value: String(port), placeholder: ""
) { editing = .port }
TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name }
TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address }
TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port }
TVFieldRow(label: "MAC", value: mac, placeholder: "Wake-on-LAN — auto-filled when known") { editingField = .mac }
HStack(spacing: 32) {
Button("Cancel", role: .cancel) { dismiss() }
Button("Add Host") { add() }
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
Button(actionTitle) { save() }.disabled(!canSave)
}
.padding(.top, 12)
}
.frame(maxWidth: 1000)
.padding(60)
.navigationTitle("Add Host")
.fullScreenCover(item: $editing) { field in
.navigationTitle(isEditing ? "Edit Host" : "Add Host")
.fullScreenCover(item: $editingField) { field in
switch field {
case .name:
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
name = $0
editing = nil
editingField = nil
}
case .address:
TVTextEntry(title: "IP or hostname", text: address) {
address = $0.trimmingCharacters(in: .whitespaces)
editing = nil
editingField = nil
}
case .port:
TVTextEntry(
title: "Port", text: String(port), keyboardType: .numberPad
) {
if let value = Int($0), (1...65535).contains(value) {
port = value
}
editing = nil
TVTextEntry(title: "Port", text: String(port), keyboardType: .numberPad) {
if let value = Int($0), (1...65535).contains(value) { port = value }
editingField = nil
}
case .mac:
TVTextEntry(title: "MAC address(es), comma-separated — aa:bb:cc:dd:ee:ff", text: mac) {
mac = $0.trimmingCharacters(in: .whitespaces)
editingField = nil
}
}
}
@@ -71,77 +91,77 @@ struct AddHostSheet: View {
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
TextField("Address", text: $address, prompt: Text("IP or hostname"))
TextField("Port", value: $port, format: .number.grouping(.never))
#if os(tvOS)
// tvOS floats the label above a non-empty field INSIDE the pill,
// shoving the value off-center the field is always prefilled
// here, so drop the label there.
.labelsHidden()
TextField("MAC", text: $mac, prompt: Text("Wake-on-LAN — auto-filled when known"))
.autocorrectionDisabled()
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
}
#if !os(tvOS)
.formStyle(.grouped)
#endif
.formStyle(.grouped)
// The grouped form's default system text is oversized next to the app's Geist
// typography bring it down and on-brand so the panel doesn't read out of place.
.font(.geist(12, relativeTo: .callout))
.controlSize(.small)
#endif
#if os(iOS)
// The detent below is sized to fit all 3 rows + the action button exactly, so the
// Form must NOT scroll/bounce inside it lock it. (iOS 16+; safe at iOS 17.)
.scrollDisabled(true)
#endif
#if os(macOS)
// macOS: UNCHANGED Cancel + Spacer + Add in an HStack, both wired to the
// window's default/cancel keyboard actions. The 380-wide .fixedSize panel below
// keeps this compact and centered.
HStack {
Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction)
Spacer()
Button("Add Host") { add() }
Button(actionTitle) { save() }
.glassProminentButtonStyle()
.keyboardShortcut(.defaultAction)
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
.disabled(!canSave)
}
.padding(16)
#else
// iOS / iPadOS: NO Cancel the sheet is dismissed by the drag indicator,
// swipe-down, or tap-outside. (AddHostSheet never sets interactiveDismissDisabled,
// so all three are live; if anyone adds it later, restore a Cancel here or there is
// no way back out.) A single FULL-WIDTH primary action reads as the one thing to do.
// The fill must be on the LABEL, not the Button: .frame(maxWidth:.infinity) on the
// Button only widens its hit area and leaves the styled capsule hugging the text
// stretching the label is what makes the glass/bordered pill itself go edge-to-edge.
// .controlSize(.large) gives the tall, thumb-friendly height; .defaultAction lets a
// hardware keyboard / iPad Return submit.
Button { add() } label: {
Text("Add Host").frame(maxWidth: .infinity)
Button { save() } label: {
Text(actionTitle).frame(maxWidth: .infinity)
}
.glassProminentButtonStyle()
.controlSize(.large)
.keyboardShortcut(.defaultAction)
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
.disabled(!canSave)
.padding(16)
#endif
}
#if os(iOS)
// A short bottom sheet, not a full-screen modal. .height(320) hugs the 3-field grouped
// Form + the full-width action row, instead of the half-screen .medium it used to rest
// at. A single fixed detent is enough: the system keeps the content above the keyboard
// when Address/Port is focused, and on iPadOS this renders as a short bottom sheet (not a
// centered formSheet card). The Form itself is .scrollDisabled (above) so it can't
// bounce/scroll inside this fixed detent. (.height(_:) is iOS 16+, safe at iOS 17.)
.presentationDetents([.height(320)])
// Four fields + the action row a touch taller than the 3-field add sheet used to be.
.presentationDetents([.height(392)])
.presentationDragIndicator(.visible)
#endif
#if os(macOS)
.frame(width: 380)
.frame(width: 400)
.fixedSize(horizontal: false, vertical: true)
#endif
#endif
}
private func add() {
onAdd(StoredHost(
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(clamping: port)))
private func save() {
var host = existing ?? StoredHost(name: "", address: "")
host.name = name.trimmingCharacters(in: .whitespaces)
host.address = address.trimmingCharacters(in: .whitespaces)
host.port = UInt16(clamping: port)
host.macAddresses = Self.parseMacs(mac)
onSave(host)
dismiss()
}
/// Split comma/space/newline-separated MACs, keep only well-formed `aa:bb:cc:dd:ee:ff` (six hex
/// octets, normalized lower-case); nil when none are valid, so clearing the field clears the
/// stored MAC.
static func parseMacs(_ s: String) -> [String]? {
let macs = s
.split(whereSeparator: { ",; \n\t".contains($0) })
.map { $0.trimmingCharacters(in: .whitespaces).lowercased() }
.filter { m in
let parts = m.split(separator: ":")
return parts.count == 6 && parts.allSatisfy { $0.count == 2 && UInt8($0, radix: 16) != nil }
}
return macs.isEmpty ? nil : macs
}
}
@@ -58,16 +58,19 @@ struct GamepadAddHostView: View {
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8)
.frame(maxWidth: .infinity)
.overlay(alignment: .topTrailing) { closeButton.padding(.trailing, 20) }
.overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
.background { GamepadTrayScrim(edge: .top) }
}
.safeAreaInset(edge: .bottom, spacing: 0) {
bottomTray
.padding(.horizontal, 22)
.padding(.vertical, compact ? 6 : 10)
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.horizontal, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 6 : 10)
.background { GamepadTrayScrim(edge: .bottom) }
}
.background { GamepadScreenBackground() }
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen.
.background { GamepadFormBackground() }
// A port can't exceed 5 digits cap while typing so the row can't grow absurd.
.onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) }
@@ -165,14 +168,16 @@ struct GamepadAddHostView: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 13)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.white.opacity(focused || editing == row.id ? 0.1 : 0))
}
// Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
// takes the brand wash, and the edited row keeps its brand caret border.
.consoleGlass(
RoundedRectangle(cornerRadius: 14, style: .continuous),
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.22 : 0),
editing == row.id ? Color.brand.opacity(0.7) : .white.opacity(focused ? 0.28 : 0.06),
lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
@@ -39,7 +39,9 @@ struct GamepadHint: Identifiable {
}
/// The pinned controls legend every gamepad screen shows bottom-leading (via `.safeAreaInset`).
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration.
/// Same font/spacing everywhere so the legend reads as system chrome, not per-screen decoration
/// worn as a self-contained Liquid Glass pill (like the top-bar controller chip) so it floats over
/// the backdrop instead of dissolving into it.
struct GamepadHintBar: View {
let hints: [GamepadHint]
@@ -57,39 +59,141 @@ struct GamepadHintBar: View {
}
.font(.geist(14, .semibold, relativeTo: .subheadline))
.foregroundStyle(.white.opacity(0.85))
.padding(13)
.consoleGlass(Capsule())
.overlay(Capsule().strokeBorder(.white.opacity(0.12), lineWidth: 1))
}
}
/// The console backdrop: a living "aurora" field in the brand's violet family soft color blobs
/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player
/// background but calmer (long 3090 s periods, muted opacities, a legibility scrim on top, so it
/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a
/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode
/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the
/// two radial gradients driven by a TimelineView give the same look with none of that risk.
/// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
/// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
/// animated `MeshGradient` a continuous silk of colour whose control points wander on slow,
/// out-of-phase sinusoids finished with an elliptical vignette (pools light in the centre, sinks
/// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
/// radial-blob field, unchanged, so nothing regresses.
///
/// Applied via `.background { }` NOT as a ZStack sibling so the `.ignoresSafeArea()` here
/// can't inflate the caller's layout past the safe area (see the layout discipline note in
/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase.
/// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
/// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
/// bundled in one of the two. MeshGradient + TimelineView give the silky look with none of that
/// risk. Applied via `.background { }` NOT a ZStack sibling so the `.ignoresSafeArea()` here
/// can't inflate the caller's layout past the safe area (see the layout note in GamepadHomeView's
/// header). Honors Reduce Motion by freezing the field at a fixed phase.
struct GamepadScreenBackground: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular
/// speeds (rad/s periods of 3090 s), and a radius that slowly breathes.
var body: some View {
Group {
if reduceMotion {
composite(at: 0)
} else {
// 30 Hz is plenty for a field that drifts centimetres per minute, and halves the
// redraw cost of a battery-fed couch device vs. the display's native rate.
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
composite(at: context.date.timeIntervalSinceReferenceDate)
}
}
}
.ignoresSafeArea()
}
/// The colour field under a very slow warm/cool hue sway, an elliptical vignette, and the
/// title/hints legibility scrim.
private func composite(at t: TimeInterval) -> some View {
ZStack {
Color.black
colorField(at: t)
// ±8° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 8))
// Cinematic vignette: darker toward the edges so the cards sit in the pooled light.
// Soft (extends past the frame) so the corners deepen rather than crush to black.
EllipticalGradient(
colors: [.clear, .black.opacity(0.42)],
center: .center, startRadiusFraction: 0.25, endRadiusFraction: 1.15)
// Legibility grounding for the pinned title (top) and hint pill (bottom). This one
// darkens the aurora itself (it's the backdrop's bottom layer nothing behind it to
// blur), so it stays a gradient, just a light one now.
LinearGradient(
stops: [
.init(color: .black.opacity(0.38), location: 0),
.init(color: .black.opacity(0.06), location: 0.32),
.init(color: .black.opacity(0.08), location: 0.68),
.init(color: .black.opacity(0.40), location: 1),
],
startPoint: .top, endPoint: .bottom)
}
}
@ViewBuilder private func colorField(at t: TimeInterval) -> some View {
if #available(iOS 18, macOS 15, tvOS 18, *) {
MeshGradient(
width: 4, height: 4,
points: Self.meshPoints(at: t),
colors: Self.meshColors,
smoothsColors: true)
} else {
LegacyBlobField(t: t)
}
}
// MARK: - MeshGradient aurora (iOS 18 / macOS 15+)
/// Sixteen mesh colours (row-major, 4×4): dark-violet corners sink the frame, the edges carry
/// mid-tone violets, and the four interior points hold the bright brand family a violet and a
/// blue-violet up top, a magenta-violet and a violet below so warm pools on the left, cool on
/// the right, and the silk shifts temperature as those interior points drift.
private static let meshColors: [Color] = {
let corner = Color(red: 0.075, green: 0.060, blue: 0.160)
return [
corner, Color(red: 0.34, green: 0.27, blue: 0.72), Color(red: 0.30, green: 0.26, blue: 0.74), corner,
Color(red: 0.42, green: 0.20, blue: 0.54), Color(red: 0.49, green: 0.39, blue: 0.95), Color(red: 0.28, green: 0.31, blue: 0.84), Color(red: 0.16, green: 0.26, blue: 0.64),
Color(red: 0.45, green: 0.23, blue: 0.60), Color(red: 0.53, green: 0.31, blue: 0.75), Color(red: 0.35, green: 0.35, blue: 0.91), Color(red: 0.19, green: 0.28, blue: 0.70),
corner, Color(red: 0.22, green: 0.18, blue: 0.54), Color(red: 0.24, green: 0.20, blue: 0.58), corner,
]
}()
/// The 4×4 control points at time `t`: every boundary point is PINNED to the frame (so the mesh
/// always fills edge-to-edge a drifting edge point would shrink the mesh and expose the black
/// behind it), while only the four interior points wander on slow, out-of-phase sinusoids
/// (periods ~90130 s) so the bright colour pools breathe without ever looking like they loop.
private static func meshPoints(at t: TimeInterval) -> [SIMD2<Float>] {
func wob(_ bx: Float, _ by: Float, _ a: Float,
_ sx: Double, _ sy: Double, _ ph: Double) -> SIMD2<Float> {
SIMD2(bx + a * Float(sin(t * sx + ph)), by + a * Float(cos(t * sy + ph * 1.3)))
}
return [
SIMD2(0, 0), SIMD2(0.333, 0), SIMD2(0.667, 0), SIMD2(1, 0),
SIMD2(0, 0.333),
wob(0.333, 0.333, 0.11, 0.049, 0.063, 0.4),
wob(0.667, 0.333, 0.10, 0.055, 0.052, 2.1),
SIMD2(1, 0.333),
SIMD2(0, 0.667),
wob(0.333, 0.667, 0.10, 0.058, 0.049, 3.6),
wob(0.667, 0.667, 0.12, 0.047, 0.061, 5.0),
SIMD2(1, 0.667),
SIMD2(0, 1), SIMD2(0.333, 1), SIMD2(0.667, 1), SIMD2(1, 1),
]
}
}
/// Pre-18/15 fallback for `GamepadScreenBackground`: the original drifting radial-blob field four
/// soft colour blobs on slow Lissajous paths, additively blended. Kept verbatim so older OSes see
/// exactly the aurora they shipped with (the mesh path is the upgrade for OS 18/15+).
private struct LegacyBlobField: View {
let t: TimeInterval
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular speeds
/// (rad/s periods of 3090 s), and a radius that slowly breathes.
private struct Blob {
let color: Color
let center: CGPoint
let drift: CGSize
let speed: (x: Double, y: Double)
let phase: (x: Double, y: Double)
/// Radius as a fraction of the view's larger dimension (+ breathing amplitude/speed).
let radius: CGFloat
let breathe: (amount: CGFloat, speed: Double)
let opacity: Double
}
/// The brand violet, a deeper indigo, a warmer plum, and a cool blue related hues so the
/// field shifts within one temperature instead of strobing through the rainbow.
private static let blobs: [Blob] = [
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet
center: CGPoint(x: 0.30, y: 0.24), drift: CGSize(width: 0.16, height: 0.10),
@@ -110,49 +214,18 @@ struct GamepadScreenBackground: View {
]
var body: some View {
Group {
if reduceMotion {
field(at: 0)
} else {
// 30 Hz is plenty for centimeters-per-minute drift, and halves the redraw cost
// of a battery-fed couch device vs. the default display rate.
TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in
field(at: context.date.timeIntervalSinceReferenceDate)
}
}
}
.ignoresSafeArea()
}
private func field(at t: TimeInterval) -> some View {
GeometryReader { geo in
let side = max(geo.size.width, geo.size.height)
ZStack {
Color.black
ZStack {
ForEach(Self.blobs.indices, id: \.self) { i in
blobView(Self.blobs[i], at: t, in: geo.size, side: side)
}
ForEach(Self.blobs.indices, id: \.self) { i in
blobView(Self.blobs[i], in: geo.size, side: side)
}
// ±10° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 10))
// Composite the additive blobs offscreen once instead of per-layer.
.drawingGroup()
// Legibility scrim: the title (top) and detail/hints (bottom) always sit on
// near-black, whatever the blobs are doing behind them.
LinearGradient(
stops: [
.init(color: .black.opacity(0.55), location: 0),
.init(color: .black.opacity(0.15), location: 0.35),
.init(color: .black.opacity(0.20), location: 0.65),
.init(color: .black.opacity(0.60), location: 1),
],
startPoint: .top, endPoint: .bottom)
}
.drawingGroup()
}
}
private func blobView(_ blob: Blob, at t: TimeInterval, in size: CGSize, side: CGFloat) -> some View {
private func blobView(_ blob: Blob, in size: CGSize, side: CGFloat) -> some View {
let x = blob.center.x + blob.drift.width * CGFloat(sin(t * blob.speed.x + blob.phase.x))
let y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
let r = side * blob.radius
@@ -168,28 +241,62 @@ struct GamepadScreenBackground: View {
}
}
/// A darkening scrim behind a pinned tray (a screen title, the hints/detail bar, the keyboard
/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row
/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar.
/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
/// render interleaved. Pure blur a dark material faded out by a gradient mask, no dark tint so
/// the tray's text sits on a softly blurred backdrop that dissolves into the rows.
struct GamepadTrayScrim: View {
let edge: VerticalEdge
var body: some View {
LinearGradient(
stops: [
.init(color: .black.opacity(0.92), location: 0),
.init(color: .black.opacity(0.85), location: 0.55),
.init(color: .black.opacity(0), location: 1),
],
startPoint: edge == .top ? .top : .bottom,
endPoint: edge == .top ? .bottom : .top)
let fromEdge: UnitPoint = edge == .top ? .top : .bottom
let toContent: UnitPoint = edge == .top ? .bottom : .top
Rectangle()
.fill(.ultraThinMaterial)
// These trays always sit on the dark console UI; force dark so the material frosts dark
// (white text stays legible) regardless of the system appearance.
.environment(\.colorScheme, .dark)
// Fade the whole blur out toward the content so it dissolves rather than ending on a line.
.mask {
LinearGradient(
stops: [
.init(color: .black, location: 0),
.init(color: .black.opacity(0.9), location: 0.5),
.init(color: .clear, location: 1),
],
startPoint: fromEdge, endPoint: toContent)
}
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds the tray's own
// text always sits on the near-opaque part, rows dim before they reach it.
// text always sits on the strong part, rows blur out before they reach it.
.padding(edge == .top ? .bottom : .top, -32)
.ignoresSafeArea()
}
}
/// The calm backdrop for the gamepad UI's form screens (settings, add-host) NOT the launcher's
/// drifting aurora (this stays still and quiet), but deliberately NOT near-black either: Liquid
/// Glass refracts whatever sits behind it, so over black the rows turn invisible. A deep indigo
/// base plus two soft, static violet/indigo glows give the glass real colour and luminance to lens,
/// so the rows read as glass while the screen stays restful.
struct GamepadFormBackground: View {
var body: some View {
ZStack {
Color(red: 0.075, green: 0.062, blue: 0.150)
// Violet lift top-leading, cooler indigo bottom-trailing resolution-independent
// (fraction radii) so the glow scale tracks the window on any screen.
EllipticalGradient(
colors: [Color(red: 0.40, green: 0.31, blue: 0.68).opacity(0.9), .clear],
center: UnitPoint(x: 0.26, y: 0.14),
startRadiusFraction: 0, endRadiusFraction: 0.78)
EllipticalGradient(
colors: [Color(red: 0.20, green: 0.24, blue: 0.58).opacity(0.75), .clear],
center: UnitPoint(x: 0.82, y: 0.9),
startRadiusFraction: 0, endRadiusFraction: 0.78)
}
.ignoresSafeArea()
}
}
/// "Which pad is driving this UI" the active controller's name and battery, worn as a quiet
/// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes.
@@ -44,8 +44,8 @@ private struct HomeTile: Identifiable {
var hasLibrary = false
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
var icon: String?
/// Whether the detail panel shows the online/paired pill (hosts yes, actions no).
var showsStatus = true
/// Offline saved host we hold a MAC for (and WoL is available) activating it wakes first.
var canWake = false
let activate: () -> Void
}
@@ -54,6 +54,9 @@ struct GamepadHomeView: View {
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
/// Wake-and-wait driver gates the carousel while its overlay is up, and the carousel's
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
@ObservedObject var waker: HostWaker
let connect: (StoredHost) -> Void
let connectDiscovered: (DiscoveredHost) -> Void
@@ -84,8 +87,11 @@ struct GamepadHomeView: View {
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
GamepadHintBar(hints: hints)
.padding(.leading, 22)
.padding(.vertical, compact ? 6 : 10)
// Equal distance from the left and bottom edges the pill's corner inset was the
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
.padding(.leading, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 4 : 8)
}
.background { GamepadScreenBackground() }
.onAppear { discovery.start() }
@@ -115,13 +121,13 @@ struct GamepadHomeView: View {
@ViewBuilder private func hero(for size: CGSize) -> some View {
let cardWidth = min(340, size.width * 0.84)
// 96 the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so
// the strip + detail always fit the region the safe-area insets leave.
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96))
// 48 the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
// always fits the region the pinned title / hints safe-area insets leave. (The old detail
// line below the strip is gone it only re-printed what the centered card already shows.)
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
VStack(spacing: compact ? 8 : 10) {
Spacer(minLength: 0)
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
detailPanel
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -155,9 +161,9 @@ struct GamepadHomeView: View {
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true },
// Stop consuming the controller while another screen is presented on top otherwise
// the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet).
isActive: libraryTarget == nil && !showSettings && !showAddHost
// Stop consuming the controller while another screen (or the wake overlay) is on top
// otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
}
@@ -186,49 +192,14 @@ struct GamepadHomeView: View {
}
}
/// The "now focused" host, spelled out below the strip empty (not hidden) so the layout
/// doesn't jump as the selection changes.
@ViewBuilder private var detailPanel: some View {
let tile = tiles.first { $0.id == selection }
VStack(spacing: 6) {
Text(tile?.title ?? " ")
.font(.geist(22, .bold, relativeTo: .title2))
.foregroundStyle(.white)
.lineLimit(1)
HStack(spacing: 10) {
Text(tile?.subtitle ?? " ")
.font(.geist(13, relativeTo: .caption))
.foregroundStyle(.white.opacity(0.6))
if let tile, tile.showsStatus {
statusPill(online: tile.isOnline, paired: tile.isPaired)
}
}
}
.frame(maxWidth: .infinity)
.padding(.horizontal, 24)
.animation(.smooth(duration: 0.25), value: selection)
}
private func statusPill(online: Bool, paired: Bool) -> some View {
HStack(spacing: 6) {
Circle()
.fill(online ? Color.green : Color.white.opacity(0.35))
.frame(width: 6, height: 6)
Text(online ? "ONLINE" : "OFFLINE")
if paired { Text("· PAIRED") }
}
.font(.geist(11, .medium, relativeTo: .caption2))
.tracking(0.8)
.foregroundStyle(.white.opacity(0.55))
}
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
private var hints: [GamepadHint] {
let selected = tiles.first { $0.id == selection }
var hints = [GamepadHint(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
text: selected?.id == .addHost ? "Add Host" : "Connect")]
text: selected?.id == .addHost ? "Add Host"
: (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
if libraryEnabled, selected?.hasLibrary == true {
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
}
@@ -252,6 +223,8 @@ struct GamepadHomeView: View {
isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
filled: true,
hasLibrary: true,
canWake: PunktfunkConnection.wakeOnLANAvailable
&& !discovery.advertises(host) && !host.wakeMacs.isEmpty,
activate: { connect(host) })
}
let discovered = discovery.unsaved(among: store.hosts).map { d in
@@ -267,7 +240,6 @@ struct GamepadHomeView: View {
title: "Add Host",
subtitle: "Register a host by address",
icon: "plus",
showsStatus: false,
activate: { showAddHost = true })
return saved + discovered + [add]
}
@@ -291,14 +263,23 @@ private struct GamepadHostTile: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) {
HStack(alignment: .top, spacing: 8) {
monogramBadge
Spacer(minLength: 0)
if tile.isOnline {
Circle()
.fill(Color.green)
.frame(width: 9, height: 9)
.shadow(color: .green.opacity(0.7), radius: 5)
// The status the removed detail panel used to spell out, now on the card itself: a
// lock for a paired (pinned-identity) host + a green pip when it's live on the LAN.
HStack(spacing: 7) {
if tile.isPaired {
Image(systemName: "lock.fill")
.font(.system(size: 11, weight: .semibold))
.foregroundStyle(.white.opacity(0.5))
}
if tile.isOnline {
Circle()
.fill(Color.green)
.frame(width: 9, height: 9)
.shadow(color: .green.opacity(0.7), radius: 5)
}
}
}
Spacer(minLength: 0)
@@ -315,11 +296,11 @@ private struct GamepadHostTile: View {
}
.padding(20)
.frame(width: size.width, height: size.height, alignment: .leading)
.background {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.fill(.ultraThinMaterial)
.environment(\.colorScheme, .dark)
}
// Liquid Glass console tile a brand wash marks a saved host as primary; discovered /
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.consoleGlass(
RoundedRectangle(cornerRadius: 26, style: .continuous),
tint: tile.filled ? Color.brand.opacity(0.20) : nil)
.overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder(
@@ -328,7 +309,6 @@ private struct GamepadHostTile: View {
startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
}
.clipShape(RoundedRectangle(cornerRadius: 26, style: .continuous))
.shadow(color: .black.opacity(0.45), radius: 20, y: 14)
}
@@ -26,8 +26,13 @@ struct HomeView: View {
let onPaired: (StoredHost, Data) -> Void
/// Picked a title in the (experimental) library start a session that launches it.
let onLaunchTitle: (StoredHost, String) -> Void
/// Explicit Wake-on-LAN of an offline host fires the packet and waits for it to come online
/// (the "Waking" overlay), without connecting. Routed through ContentView's HostWaker.
let wake: (StoredHost) -> Void
/// Experimental game-library browser (gated) the host-card "Browse Library" action.
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false
/// The host being edited (name / address / port / Wake-on-LAN MAC) drives the edit sheet.
@State private var editTarget: StoredHost?
var body: some View {
NavigationStack {
@@ -126,6 +131,13 @@ struct HomeView: View {
.sheet(isPresented: $showAddHost) {
AddHostSheet { store.add($0) }
}
.sheet(item: $editTarget) { host in
// Prefill the MAC from the live advert when the host hasn't stored one yet.
AddHostSheet(
existing: host,
suggestedMacs: discovery.hosts.first { host.matches($0) }?.macAddresses ?? [],
onSave: { store.update($0) })
}
#if os(iOS)
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it
// is presented directly wrapping it in a NavigationStack here would nest a split view in
@@ -155,13 +167,8 @@ struct HomeView: View {
onForget: { store.forgetIdentity(host) },
onRemove: { store.remove(host) },
onBrowseLibrary: onBrowseLibrary,
onWake: {
let macs = host.wakeMacs
let ip = host.address
DispatchQueue.global(qos: .userInitiated).async {
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
}
})
onWake: { wake(host) },
onEdit: { editTarget = host })
}
private var discoveredSection: some View {
@@ -89,6 +89,8 @@ struct HostCardView: View {
/// Send a Wake-on-LAN magic packet. Shown only when the host is offline and we have a stored
/// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
var onWake: (() -> Void)? = nil
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
var onEdit: (() -> Void)? = nil
var body: some View {
let m = CardMetrics.current
@@ -136,6 +138,9 @@ struct HostCardView: View {
#endif
.disabled(isBusy)
.contextMenu {
if let onEdit {
Button("Edit…", systemImage: "pencil", action: onEdit)
}
Button("Pair with PIN…", action: onPair)
Button("Test Network Speed…", action: onSpeedTest)
if let onBrowseLibrary {
@@ -0,0 +1,84 @@
// The "Waking <host>" modal shown while HostWaker brings a sleeping host back a spinner + a
// live elapsed counter, escalating to a retry/cancel prompt on timeout. Presented over BOTH the
// touch and gamepad home (a wake only ever starts on macOS today, where WoL is ungated), and it
// drives from either a pointer (the buttons) or a controller (B cancels, A retries once timed out).
import PunktfunkKit
import SwiftUI
struct WakeOverlay: View {
@ObservedObject var waker: HostWaker
var body: some View {
if let w = waker.waking {
ZStack {
// Dim + swallow input to the home behind it.
Rectangle().fill(.black.opacity(0.6)).ignoresSafeArea()
.contentShape(Rectangle())
.onTapGesture {}
card(w)
.frame(maxWidth: 380)
.padding(28)
.consoleGlass(RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(.white.opacity(0.12), lineWidth: 1))
.padding(40)
}
.environment(\.colorScheme, .dark)
.transition(.opacity)
#if os(iOS) || os(macOS)
.background { WakeControllerInput(waker: waker) }
#endif
}
}
@ViewBuilder private func card(_ w: HostWaker.Waking) -> some View {
VStack(spacing: 14) {
if w.timedOut {
Image(systemName: "moon.zzz.fill")
.font(.system(size: 34)).foregroundStyle(.white.opacity(0.85))
Text("\(w.hostName) didn't wake")
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
Text("It may still be booting, or it's powered off / off this network.")
.font(.geist(13, relativeTo: .caption)).foregroundStyle(.white.opacity(0.6))
.multilineTextAlignment(.center)
HStack(spacing: 12) {
Button("Cancel") { waker.cancel() }.buttonStyle(.bordered)
Button("Try Again") { waker.retry() }.glassProminentButtonStyle()
}
.padding(.top, 6)
} else {
ProgressView().controlSize(.large).tint(.white)
Text("Waking \(w.hostName)")
.font(.geist(19, .bold, relativeTo: .title3)).foregroundStyle(.white)
Text("Waiting for it to come online · \(w.seconds)s")
.font(.geistFixed(13)).foregroundStyle(.white.opacity(0.6))
.monospacedDigit()
Button(w.connectsAfter ? "Cancel" : "Stop Waiting") { waker.cancel() }
.buttonStyle(.bordered)
.padding(.top, 6)
}
}
}
}
#if os(iOS) || os(macOS)
/// Controller binding for the overlay: B cancels; A retries once it has timed out. A zero-size
/// backing view owning a `GamepadMenuInput` for the overlay's lifetime (the home carousel/list is
/// gated inactive while a wake is up, so nothing else is consuming the pad).
private struct WakeControllerInput: View {
@ObservedObject var waker: HostWaker
@State private var input = GamepadMenuInput(manager: .shared)
var body: some View {
Color.clear
.onAppear {
input.onBack = { waker.cancel() }
input.onConfirm = { if waker.waking?.timedOut == true { waker.retry() } }
input.start()
}
.onDisappear { input.stop() }
}
}
#endif
@@ -18,23 +18,47 @@ struct ShotScene {
@MainActor
enum ShotScenes {
static let all: [ShotScene] = [
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotStreamHero())
},
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
AnyView(ShotHome())
},
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
AnyView(ShotPair())
},
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotTrust())
},
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
AnyView(ShotSettings())
},
]
static var all: [ShotScene] {
var scenes: [ShotScene] = [
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotStreamHero())
},
ShotScene(name: "02-hosts", orientation: .natural, colorScheme: .dark) {
AnyView(ShotHome())
},
ShotScene(name: "03-pair", orientation: .natural, colorScheme: .dark) {
AnyView(ShotPair())
},
ShotScene(name: "04-trust", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotTrust())
},
ShotScene(name: "05-settings", orientation: .natural, colorScheme: .dark) {
AnyView(ShotSettings())
},
]
#if os(iOS) || os(macOS)
// The gamepad-mode console screens (no tvOS native focus engine there). Dev-only shots
// for eyeballing the Liquid Glass host tiles + settings rows.
scenes += [
ShotScene(name: "06-gamepad-home", orientation: .natural, colorScheme: .dark) {
AnyView(ShotGamepadHome())
},
ShotScene(name: "07-gamepad-settings", orientation: .natural, colorScheme: .dark) {
AnyView(ShotGamepadSettings())
},
ShotScene(name: "08-gamepad-addhost", orientation: .natural, colorScheme: .dark) {
AnyView(ShotGamepadAddHost())
},
ShotScene(name: "09-waking", orientation: .natural, colorScheme: .dark) {
AnyView(ShotWaking())
},
]
#endif
scenes.append(ShotScene(name: "10-edithost", orientation: .natural, colorScheme: .dark) {
AnyView(ShotEditHost())
})
return scenes
}
}
// MARK: - Mock data
@@ -75,7 +99,7 @@ private struct ShotHome: View {
showAddHost: .constant(false), pairingTarget: .constant(nil),
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
connect: { _ in }, connectDiscovered: { _ in },
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
#else
HomeView(
store: store, model: model, discovery: discovery,
@@ -83,11 +107,77 @@ private struct ShotHome: View {
speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
showSettings: .constant(false),
connect: { _ in }, connectDiscovered: { _ in },
onPaired: { _, _ in }, onLaunchTitle: { _, _ in })
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
#endif
}
}
// MARK: - Gamepad-mode console screens (dev-only glass preview)
#if os(iOS) || os(macOS)
private struct ShotGamepadHome: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var waker = HostWaker()
var body: some View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
connect: { _ in }, connectDiscovered: { _ in })
}
}
private struct ShotGamepadSettings: View {
var body: some View { GamepadSettingsView() }
}
private struct ShotGamepadAddHost: View {
var body: some View { GamepadAddHostView(onAdd: { _ in }) }
}
private struct ShotWaking: View {
@StateObject private var store = ShotMock.hostStore()
@StateObject private var model = SessionModel()
@StateObject private var discovery = HostDiscovery()
@StateObject private var waker = HostWaker()
var body: some View {
GamepadHomeView(
store: store, model: model, discovery: discovery,
libraryTarget: .constant(nil), waker: waker,
connect: { _ in }, connectDiscovered: { _ in }
)
.overlay { WakeOverlay(waker: waker) }
.onAppear {
waker.debugSet(.init(
hostID: store.hosts.first?.id ?? UUID(),
hostName: "Battlestation", connectsAfter: true, seconds: 14))
}
}
}
#endif
// MARK: - Edit host (add/edit sheet with the Wake-on-LAN MAC field)
private struct ShotEditHost: View {
var body: some View {
ZStack {
ShotHome().blur(radius: 24).overlay(Color.black.opacity(0.45))
AddHostSheet(
existing: StoredHost(
name: "Battlestation", address: "192.168.1.20", port: 9777,
pinnedSHA256: ShotMock.fingerprint, macAddresses: ["a4:b1:c2:d3:e4:f5"]),
onSave: { _ in })
#if os(macOS)
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(radius: 40, y: 16)
#endif
}
}
}
// MARK: - Settings
private struct ShotSettings: View {
@@ -0,0 +1,112 @@
// Wake a sleeping host and WAIT for it to come back before proceeding.
//
// A magic packet is fire-and-forget, and a cold box can take 2060 s to POST, boot, and start
// advertising on mDNS again far longer than a connect attempt will sit. The old path fired a
// packet and immediately dialed, so a genuinely-asleep host just failed. This drives a visible
// "Waking" state instead: it (re-)sends the packet, polls the host's mDNS presence once a second,
// and on success runs `onOnline` (the real connect for a Wake-&-Connect, or nothing for an explicit
// wake-only); on timeout it parks in a retry/cancel state. One wake at a time.
import Foundation
import PunktfunkKit
import SwiftUI
@MainActor
final class HostWaker: ObservableObject {
struct Waking: Equatable {
let hostID: UUID
let hostName: String
/// Whether coming online chains into a connect (Wake & Connect) vs. just stopping.
let connectsAfter: Bool
var seconds = 0
var timedOut = false
}
/// nil = idle; non-nil drives `WakeOverlay`.
@Published private(set) var waking: Waking?
/// How long to wait for the host to reappear before giving up. Generous a cold boot + service
/// start can be a minute-plus.
private let timeoutSeconds = 90
/// Re-send the packet this often: a single one can be missed, and some NICs only wake on a fresh
/// packet after dropping into a deeper sleep state.
private let resendEverySeconds = 6
private var loop: Task<Void, Never>?
/// Captured so "Try Again" replays the exact same wait.
private var replay: (() -> Void)?
/// Wake `host` and wait for `isOnline()` to go true, then run `onOnline`. `macs`/`lastIP` target
/// the magic packet. No-ops straight to `onOnline` when there's nothing to wake with or the host
/// is already up (a race between the caller's check and here).
func start(
host: StoredHost, connectsAfter: Bool,
macs: [String], lastIP: String?,
isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void
) {
guard !macs.isEmpty, !isOnline() else {
cancel()
onOnline()
return
}
replay = { [weak self] in
self?.run(host: host, connectsAfter: connectsAfter, macs: macs, lastIP: lastIP,
isOnline: isOnline, onOnline: onOnline)
}
replay?()
}
/// Stop waiting and dismiss the overlay (B / Cancel).
func cancel() {
loop?.cancel()
loop = nil
replay = nil
waking = nil
}
/// Restart the wait after a timeout (A / Try Again).
func retry() { replay?() }
private func run(
host: StoredHost, connectsAfter: Bool, macs: [String], lastIP: String?,
isOnline: @escaping () -> Bool, onOnline: @escaping () -> Void
) {
loop?.cancel()
waking = Waking(hostID: host.id, hostName: host.displayName, connectsAfter: connectsAfter)
let timeout = timeoutSeconds
let resend = resendEverySeconds
loop = Task { [weak self] in
var elapsed = 0
while !Task.isCancelled {
if elapsed % resend == 0 { Self.sendPacket(macs: macs, lastIP: lastIP) }
if isOnline() {
guard let self, !Task.isCancelled else { return }
self.waking = nil
self.loop = nil
onOnline()
return
}
if elapsed >= timeout {
self?.waking?.timedOut = true
self?.loop = nil
return
}
try? await Task.sleep(nanoseconds: 1_000_000_000)
elapsed += 1
self?.waking?.seconds = elapsed
}
}
}
/// Blocking sends (see PunktfunkConnection.wakeOnLAN) off the main thread.
private static func sendPacket(macs: [String], lastIP: String?) {
DispatchQueue.global(qos: .userInitiated).async {
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: lastIP)
}
}
#if DEBUG
/// Force a static waking state for the screenshot harness (no timers, no packets).
func debugSet(_ w: Waking) { waking = w }
#endif
}
@@ -81,13 +81,17 @@ struct GamepadSettingsView: View {
.init(glyph: buttonGlyph(\.buttonB, fallback: "b.circle"), text: "Done"),
])
}
.padding(.leading, 22)
// Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.leading, compact ? 12 : 18)
.padding(.trailing, 22)
.padding(.vertical, compact ? 6 : 10)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 6 : 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .bottom) }
}
.background { GamepadScreenBackground() }
// No aurora here the settings read as clean Liquid Glass over a quiet dark base, so the
// glass rows are the only material on the screen.
.background { GamepadFormBackground() }
.onAppear {
gamepads.refresh()
gamepads.startDiscovery()
@@ -148,13 +152,14 @@ struct GamepadSettingsView: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 13)
.background {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.white.opacity(focused ? 0.1 : 0))
}
// Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
.consoleGlass(
RoundedRectangle(cornerRadius: 14, style: .continuous),
tint: focused ? Color.brand.opacity(0.30) : nil,
interactive: focused)
.overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder(.white.opacity(focused ? 0.22 : 0), lineWidth: 1)
.strokeBorder(.white.opacity(focused ? 0.28 : 0.06), lineWidth: 1)
}
.scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused)
@@ -98,6 +98,13 @@ final class HostStore: ObservableObject {
hosts.removeAll { $0.id == host.id }
}
/// Replace a saved host in place (the edit sheet) matched by id, so identity/pin/last-connected
/// carried on the passed value are preserved.
func update(_ host: StoredHost) {
guard let i = hosts.firstIndex(where: { $0.id == host.id }) else { return }
hosts[i] = host
}
func markConnected(_ hostID: UUID) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].lastConnected = Date()
@@ -67,3 +67,41 @@ extension View {
modifier(GlassProminentButton())
}
}
// MARK: - Console glass (gamepad host tiles + settings rows)
/// Liquid Glass tuned for the gamepad UI's dark "console" surfaces the host-carousel tiles and
/// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately
/// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the
/// glass toward a color (the brand violet on the focused / primary surface); `interactive` makes
/// it flex on press. The pre-26 fallback is `.ultraThinMaterial` forced dark these surfaces
/// always sit on the near-black backdrop, so the material must stay dark even in a light appearance.
private struct ConsoleGlass<S: Shape>: ViewModifier {
let shape: S
var tint: Color?
var interactive = false
func body(content: Content) -> some View {
if #available(iOS 26, macOS 26, tvOS 26, *) {
content.glassEffect(glass, in: shape)
} else {
content.background { shape.fill(.ultraThinMaterial).environment(\.colorScheme, .dark) }
}
}
@available(iOS 26, macOS 26, tvOS 26, *)
private var glass: Glass {
var g: Glass = .regular
if let tint { g = g.tint(tint) }
if interactive { g = g.interactive() }
return g
}
}
extension View {
/// Liquid Glass for a dark console surface (a host tile / settings row), or `.ultraThinMaterial`
/// (forced dark) pre-26. Pass the surface's shape explicitly glass defaults to a Capsule.
func consoleGlass<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive))
}
}
@@ -103,7 +103,7 @@ struct PairSheet: View {
TextField(
"PIN", text: $pin,
prompt: Text("Shown in the host's web console"))
.font(.system(.title3, design: .monospaced))
.font(.geistFixed(16)) // prominent, but on-brand mono (not oversized title3)
#if os(iOS)
.keyboardType(.numberPad)
#endif
@@ -134,6 +134,11 @@ struct PairSheet: View {
}
#if !os(tvOS)
.formStyle(.grouped)
// Bring the grouped form's default system text down to the app's Geist scale so the sheet
// doesn't read oversized / out of place (matches AddHostSheet). The PIN field keeps its own
// explicit Geist Mono font.
.font(.geist(12, relativeTo: .callout))
.controlSize(.small)
#endif
HStack {
Button("Cancel", role: .cancel) {
+26 -2
View File
@@ -73,6 +73,14 @@ struct Args {
/// `--rich-input-test` — drive the DualSense touchpad + motion over 0xCC (host needs
/// `PUNKTFUNK_GAMEPAD=dualsense`); also logs the 0xCD HID-output feedback that comes back.
rich_input_test: bool,
/// `--quit` — close the connection with the deliberate-quit code (`QUIT_CLOSE_CODE`) at end of
/// stream, so the host tears its virtual display down immediately (skips keep-alive linger). A
/// bare exit closes with code 0 → the host lingers for a reconnect. Tests the #2 quit path.
quit: bool,
/// `--seconds N` — cap the receive loop at N seconds, then end the session gracefully (reach the
/// `conn.close`). Without it the loop runs to the 120s cap. Lets a test bound a live-host stream so
/// the client-initiated close (with/without `--quit`) fires promptly.
seconds: Option<u64>,
pin: Option<[u8; 32]>,
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
remode: Option<(Mode, u32)>,
@@ -211,6 +219,8 @@ fn parse_args() -> Args {
mic_burst: argv.iter().any(|a| a == "--mic-burst"),
touch_test: argv.iter().any(|a| a == "--touch-test"),
rich_input_test: argv.iter().any(|a| a == "--rich-input-test"),
quit: argv.iter().any(|a| a == "--quit"),
seconds: get("--seconds").and_then(|s| s.parse().ok()),
pin,
remode,
pair: get("--pair").map(String::from),
@@ -1041,6 +1051,9 @@ async fn session(args: Args) -> Result<()> {
let mut net_us_v: Vec<u64> = Vec::new();
let mut last_rx = std::time::Instant::now();
let started = std::time::Instant::now();
// Stream-duration cap: `--seconds N`, else the 120s default. Ending the loop here reaches the
// graceful `conn.close` below (with the deliberate-quit code if `--quit`).
let cap_secs = args.seconds.unwrap_or(120);
// Adaptive-FEC loss window: publish a fresh estimate every 750 ms for the LossReport task.
let mut last_loss_report = std::time::Instant::now();
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
@@ -1076,7 +1089,7 @@ async fn session(args: Args) -> Result<()> {
{
break;
}
if started.elapsed() > std::time::Duration::from_secs(120)
if started.elapsed() > std::time::Duration::from_secs(cap_secs)
|| last_rx.elapsed() > std::time::Duration::from_secs(8)
{
break;
@@ -1208,7 +1221,18 @@ async fn session(args: Args) -> Result<()> {
}
}
conn.close(0u32.into(), b"done");
// `--quit` closes with the deliberate-quit code so the host skips the keep-alive linger; a normal
// exit uses code 0 (an unwanted-disconnect close → the host lingers for a reconnect).
let close_code = if args.quit {
punktfunk_core::quic::QUIT_CLOSE_CODE
} else {
0
};
conn.close(close_code.into(), b"done");
// Flush the CONNECTION_CLOSE frame before we exit: without this the process can drop the endpoint
// before quinn sends the close, so the host waits out the idle timeout instead of seeing the close
// CODE promptly (deliberate-quit vs. code 0). Bounded so a stuck flush can't hang the probe.
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), ep.wait_idle()).await;
result
}
+28 -1
View File
@@ -179,6 +179,10 @@ pub struct NativeClient {
/// Speed-test accumulator, shared with the data-plane pump + control task.
probe: Arc<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>,
/// Deliberate-quit flag: [`NativeClient::disconnect_quit`] sets it, so the worker closes the QUIC
/// connection with [`crate::quic::QUIT_CLOSE_CODE`] (a user "stop") instead of code 0 — telling the
/// host to skip the keep-alive linger. A plain drop leaves it false → an unwanted-disconnect close.
quit: Arc<AtomicBool>,
/// Cumulative count of access units the reassembler gave up on (FEC couldn't recover), mirrored
/// from the data-plane pump's `Session`. A client video loop watches this for increases to request
/// a recovery keyframe under infinite GOP — the correct loss trigger, since unrecoverable loss
@@ -331,6 +335,7 @@ impl NativeClient {
let (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<CtrlRequest>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
let shutdown = Arc::new(AtomicBool::new(false));
let quit = Arc::new(AtomicBool::new(false));
let mode_slot = Arc::new(std::sync::Mutex::new(mode));
let probe = Arc::new(Mutex::new(ProbeState::default()));
let frames_dropped = Arc::new(AtomicU64::new(0));
@@ -338,6 +343,7 @@ impl NativeClient {
let host = host.to_string();
let shutdown_w = shutdown.clone();
let quit_w = quit.clone();
let mode_slot_w = mode_slot.clone();
let probe_w = probe.clone();
let frames_dropped_w = frames_dropped.clone();
@@ -388,6 +394,7 @@ impl NativeClient {
ctrl_tx: ctrl_tx_pump,
ready_tx,
shutdown: shutdown_w,
quit: quit_w,
mode_slot: mode_slot_w,
probe: probe_w,
frames_dropped: frames_dropped_w,
@@ -430,6 +437,7 @@ impl NativeClient {
ctrl_tx,
probe,
shutdown,
quit,
worker: Some(worker),
frames_dropped,
hot_tids,
@@ -764,6 +772,15 @@ impl NativeClient {
.send(rich)
.map_err(|_| PunktfunkError::Closed)
}
/// Signal a **deliberate quit** (a user "stop", not a network drop): the worker closes the QUIC
/// connection with [`crate::quic::QUIT_CLOSE_CODE`] instead of code 0, so the host tears the
/// session's virtual display down immediately and skips the keep-alive linger. Then requests
/// shutdown. A plain `drop` (without this) closes with code 0 → the host lingers for a reconnect.
pub fn disconnect_quit(&self) {
self.quit.store(true, Ordering::SeqCst);
self.shutdown.store(true, Ordering::SeqCst);
}
}
impl Drop for NativeClient {
@@ -802,6 +819,8 @@ struct WorkerArgs {
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>,
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
shutdown: Arc<AtomicBool>,
/// Deliberate-quit flag (see [`NativeClient::quit`]): the worker closes with the quit code if set.
quit: Arc<AtomicBool>,
mode_slot: Arc<std::sync::Mutex<Mode>>,
probe: Arc<Mutex<ProbeState>>,
frames_dropped: Arc<AtomicU64>,
@@ -838,6 +857,7 @@ async fn worker_main(args: WorkerArgs) {
ctrl_tx,
ready_tx,
shutdown,
quit,
mode_slot,
probe,
frames_dropped,
@@ -1210,5 +1230,12 @@ async fn worker_main(args: WorkerArgs) {
})
.await;
conn.close(0u32.into(), b"client closed");
// Deliberate quit (a user "stop") closes with the quit code → the host skips the keep-alive
// linger; a plain drop / disconnect closes with 0 → the host lingers so a reconnect can resume.
let close_code = if quit.load(Ordering::SeqCst) {
crate::quic::QUIT_CLOSE_CODE
} else {
0
};
conn.close(close_code.into(), b"client closed");
}
+45 -13
View File
@@ -122,6 +122,13 @@ pub const VIDEO_CAP_444: u8 = 0x04;
/// stage. Purely observability — never changes what the host encodes.
pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08;
/// QUIC application error code a punktfunk/1 client closes the control connection with on a
/// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
/// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the
/// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a
/// reconnect can resume. Shared so host + every client agree on the code.
pub const QUIT_CLOSE_CODE: u32 = 0x51;
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
/// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
/// advertise this.
@@ -1743,20 +1750,31 @@ pub mod endpoint {
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so
/// several keepalives can be lost back-to-back (a wifi roam, a brief blip) without a false
/// close, while a genuinely dead peer is still detected within `MAX_IDLE`.
/// The default control-connection idle timeout (disconnect-detection latency). A vanished client
/// is declared dead within this window — the Windows IDD-push path needs it short so a RECONNECT
/// recreates a fresh virtual monitor instead of joining the still-lingering old session; the Linux
/// path pairs it with the same-client reconnect preempt. Host-tunable via `server_with_identity_idle`.
pub const DEFAULT_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
fn stream_transport() -> Arc<quinn::TransportConfig> {
stream_transport_idle(DEFAULT_IDLE_TIMEOUT)
}
/// Transport config with a caller-chosen idle timeout (disconnect-detection latency). The
/// keep-alive interval tracks it at half the idle window (capped at the default 4s), so a live
/// path is PINGed at least twice per window and a single lost PING (wifi roam / brief blip) won't
/// false-close. `idle` is clamped to a ≥1s floor so a misconfigured tiny value can't tear live
/// sessions down. Active sessions are unaffected either way: video keeps the connection live and
/// the keep-alive holds it open through quiet control periods.
fn stream_transport_idle(idle: std::time::Duration) -> Arc<quinn::TransportConfig> {
use std::time::Duration;
// 8s idle (was 20s): a vanished client is declared dead within 8s instead of 20, so its
// session tears down promptly — which the Windows IDD-push path needs so a RECONNECT recreates
// a fresh virtual monitor (a reused monitor's IddCx swap-chain dies) instead of joining the
// still-lingering old session. Active sessions are unaffected: video keeps the connection live,
// and the 4s keep-alive holds it open through quiet control periods.
const MAX_IDLE: Duration = Duration::from_secs(8);
const KEEP_ALIVE: Duration = Duration::from_secs(4);
let idle = idle.max(Duration::from_secs(1));
let keep_alive = (idle / 2).min(Duration::from_secs(4));
let mut t = quinn::TransportConfig::default();
t.max_idle_timeout(Some(
quinn::IdleTimeout::try_from(MAX_IDLE).expect("8s is a valid QUIC idle timeout"),
quinn::IdleTimeout::try_from(idle).expect("clamped idle timeout is a valid QUIC value"),
));
t.keep_alive_interval(Some(KEEP_ALIVE));
t.keep_alive_interval(Some(keep_alive));
Arc::new(t)
}
@@ -1767,23 +1785,36 @@ pub mod endpoint {
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der());
server_from_der(cert_der, key_der.into(), addr)
server_from_der(cert_der, key_der.into(), addr, DEFAULT_IDLE_TIMEOUT)
}
/// Server endpoint from a persisted PEM identity (certificate + PKCS#8 private key) —
/// the host's long-lived self-signed cert, so the fingerprint clients pin is stable
/// across restarts.
/// across restarts. Uses the [`DEFAULT_IDLE_TIMEOUT`]; see [`server_with_identity_idle`] to tune it.
pub fn server_with_identity(
addr: std::net::SocketAddr,
cert_pem: &str,
key_pem: &str,
) -> anyhow_result::Result<quinn::Endpoint> {
server_with_identity_idle(addr, cert_pem, key_pem, DEFAULT_IDLE_TIMEOUT)
}
/// Like [`server_with_identity`] but with a host-chosen control-connection idle timeout — the
/// disconnect-detection latency (how long a vanished client takes to be declared dead). Shorter =
/// faster teardown/linger of a dropped session; the value is clamped to a ≥1s floor and its
/// keep-alive scales with it so a live session never false-closes.
pub fn server_with_identity_idle(
addr: std::net::SocketAddr,
cert_pem: &str,
key_pem: &str,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> {
use rustls::pki_types::pem::PemObject;
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("cert pem: {e}")))?;
let key_der = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
.map_err(|e| anyhow_result::Error::msg(format!("key pem: {e}")))?;
server_from_der(cert_der, key_der, addr)
server_from_der(cert_der, key_der, addr, idle)
}
/// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
@@ -1796,6 +1827,7 @@ pub mod endpoint {
cert_der: rustls::pki_types::CertificateDer<'static>,
key_der: rustls::pki_types::PrivateKeyDer<'static>,
addr: std::net::SocketAddr,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> {
let _ = rustls::crypto::ring::default_provider().install_default();
// Client auth is OFFERED but optional: a client that presents its self-signed
@@ -1810,7 +1842,7 @@ pub mod endpoint {
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg));
server_config.transport_config(stream_transport()); // keep-alive — see stream_transport
server_config.transport_config(stream_transport_idle(idle)); // keep-alive — see stream_transport_idle
Ok(quinn::Endpoint::server(server_config, addr)?)
}
+18 -2
View File
@@ -416,7 +416,14 @@ impl UdpTransport {
/// Bind `local` and `connect` to `peer`, so `send`/`recv` need no address and the
/// kernel filters to this peer. Non-blocking, matching the [`Transport`] contract.
pub fn connect(local: &str, peer: &str) -> std::io::Result<Self> {
let socket = UdpSocket::bind(local)?;
Self::from_socket(UdpSocket::bind(local)?, peer)
}
/// Adopt an already-bound socket for the data plane: `connect` it to `peer`, tune buffers +
/// QoS, go non-blocking. Lets the host bind the data port up front (e.g. a fixed `--data-port`)
/// and keep the *same* socket from handshake through streaming — no drop-then-rebind window in
/// which a concurrent session could steal a fixed port.
pub fn from_socket(socket: UdpSocket, peer: &str) -> std::io::Result<Self> {
socket.connect(peer)?;
super::qos::grow_socket_buffers(&socket);
// The native data plane is video-dominant — tag it as the video class (opt-in via
@@ -438,7 +445,16 @@ impl UdpTransport {
fallback_peer: &str,
punch_timeout: std::time::Duration,
) -> std::io::Result<(Self, bool)> {
let socket = UdpSocket::bind(local)?;
Self::from_socket_punch(UdpSocket::bind(local)?, fallback_peer, punch_timeout)
}
/// [`connect_via_punch`](Self::connect_via_punch) on an already-bound socket — see
/// [`from_socket`](Self::from_socket) for why the host binds the data port up front.
pub fn from_socket_punch(
socket: UdpSocket,
fallback_peer: &str,
punch_timeout: std::time::Duration,
) -> std::io::Result<(Self, bool)> {
socket.set_read_timeout(Some(punch_timeout))?;
let deadline = std::time::Instant::now() + punch_timeout;
let mut buf = [0u8; 64];
+37 -3
View File
@@ -170,18 +170,26 @@ pub fn appasset_bytes(appid: u32) -> Option<(Vec<u8>, String)> {
/// Render the GameStream `/applist` XML. `IsHdrSupported` reflects whether the host can actually deliver
/// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
/// true, Moonlight offers its per-app HDR toggle.
///
/// The document is emitted **COMPACT — no whitespace between elements** — deliberately, to match
/// Sunshine/GFE. Moonlight-Android's `getAppListByReader` calls `appList.getLast()` on *every* XML
/// text node before it checks the current tag, and only fills `appList` on an `<App>` start tag. A
/// pretty-print newline between `<root>` and the first `<App>` is a whitespace text node while
/// `appList` is still empty → `NoSuchElementException` → the Android app hard-crashes on host click.
/// (iOS/macOS parse via moonlight-common-c/expat and are unaffected; `serverinfo`/pairing use the
/// named-tag `getXmlString` scan, so their whitespace is harmless.) Keep this whitespace-free.
pub fn applist_xml() -> String {
let hdr = u8::from(crate::gamestream::host_hdr_capable());
let mut xml =
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n");
String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?><root status_code=\"200\">");
for app in catalog() {
xml.push_str(&format!(
"<App>\n<IsHdrSupported>{hdr}</IsHdrSupported>\n<AppTitle>{}</AppTitle>\n<ID>{}</ID>\n</App>\n",
"<App><IsHdrSupported>{hdr}</IsHdrSupported><AppTitle>{}</AppTitle><ID>{}</ID></App>",
xml_escape(&app.title),
app.id
));
}
xml.push_str("</root>\n");
xml.push_str("</root>");
xml
}
@@ -249,4 +257,30 @@ mod tests {
assert!(xml.starts_with("<?xml"));
assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count());
}
/// Regression: the applist MUST be whitespace-free between elements. Moonlight-Android's
/// `getAppListByReader` calls `appList.getLast()` on every text node before an `<App>` has been
/// pushed, so a pretty-print newline between `<root>` and the first `<App>` crashes the app
/// (`NoSuchElementException`). Reproduced on 2 Android phones; iOS/macOS (moonlight-common-c)
/// were unaffected. Keep `applist_xml` compact like Sunshine/GFE.
#[test]
fn applist_xml_has_no_interelement_whitespace() {
let xml = applist_xml();
// <root> is immediately followed by the first <App> — no whitespace text node while the
// parser's app list is still empty.
assert!(
xml.contains("status_code=\"200\"><App>"),
"no whitespace between <root> and the first <App>: {xml}"
);
// No pretty-print newlines anywhere in the element stream, and no whitespace-only text
// nodes between any adjacent tags.
assert!(
!xml.contains('\n'),
"applist must contain no newlines: {xml}"
);
assert!(
!xml.contains("> <"),
"applist must contain no inter-element spaces: {xml}"
);
}
}
@@ -108,6 +108,11 @@ pub struct LaunchSession {
/// unpaired RTSP peer cannot ride a paired client's launch (security-review 2026-06-28 #4).
/// `None` if the address could not be captured (then RTSP falls back to launch-present only).
pub peer_ip: Option<std::net::IpAddr>,
/// SHA-256 cert fingerprint of the paired client that owns this session — mode-conflict admission
/// (Stage 4) compares it against a launching client to tell a same-client re-launch (always
/// allowed) from a DIFFERENT client (subject to the `mode_conflict` policy). `[u8; 32]` keeps
/// [`LaunchSession`] `Copy`; `None` when the peer cert couldn't be read.
pub owner_fp: Option<[u8; 32]>,
}
/// Shared control-plane state used as the axum app state.
+131 -5
View File
@@ -126,15 +126,59 @@ async fn h_launch(
peer: Option<Extension<PeerCertFingerprint>>,
addr: Option<Extension<PeerAddr>>,
Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse {
) -> Response {
if !peer_is_paired(&peer, &st) {
tracing::warn!("launch rejected — client is not paired");
return xml(error_xml());
return xml(error_xml()).into_response();
}
let req_fp: Option<[u8; 32]> = match &peer {
Some(Extension(PeerCertFingerprint(Some(fp)))) => hex::decode(fp)
.ok()
.and_then(|v| <[u8; 32]>::try_from(v).ok()),
_ => None,
};
// Mode-conflict ADMISSION (Stage 4) — GameStream is single-session (`st.launch`), so a DIFFERENT
// paired client launching while a session is live is governed by `mode_conflict` (see
// [`gamestream_admission`]). Snapshot the live owner + mode (Copy) so the lock isn't held over it.
let mut forced_mode: Option<(u32, u32, u32)> = None;
{
let live = st
.launch
.lock()
.unwrap()
.as_ref()
.map(|s| (s.owner_fp, (s.width, s.height, s.fps)));
// Same Windows default as the native path (separate → reject; see `effective_conflict`) so a
// 2nd Moonlight client gets a clean 503 rather than wedging the shared monitor's capture.
let conflict = crate::vdisplay::admission::effective_conflict();
match gamestream_admission(live, req_fp, conflict) {
GsDecision::Serve => {}
GsDecision::Join((w, h, f)) => {
forced_mode = Some((w, h, f));
tracing::info!(
"GameStream launch JOIN — admitting at the live session's mode {w}x{h}@{f}"
);
}
GsDecision::Reject => {
tracing::warn!(
"GameStream launch REJECTED — host busy (mode_conflict=reject, session owned by another client)"
);
return (StatusCode::SERVICE_UNAVAILABLE, xml(error_xml())).into_response();
}
}
}
match launch(&st, &q) {
Ok(mut session) => {
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP.
session.peer_ip = addr.map(|Extension(PeerAddr(a))| a.ip());
session.owner_fp = req_fp;
if let Some((w, h, f)) = forced_mode {
session.width = w;
session.height = h;
session.fps = f;
}
*st.launch.lock().unwrap() = Some(session);
tracing::info!(
w = session.width,
@@ -144,11 +188,11 @@ async fn h_launch(
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip
);
xml(session_url_xml(&st, "gamesession"))
xml(session_url_xml(&st, "gamesession")).into_response()
}
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "launch failed");
xml(error_xml())
xml(error_xml()).into_response()
}
}
}
@@ -210,7 +254,8 @@ fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession>
height,
fps,
appid,
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
peer_ip: None, // set by `h_launch` from the verified HTTPS peer address
owner_fp: None, // set by `h_launch` from the verified HTTPS peer cert fingerprint
})
}
@@ -223,6 +268,48 @@ fn parse_mode(mode: &str) -> Option<(u32, u32, u32)> {
Some((w, h, fps))
}
/// A live GameStream session's `(owner cert fingerprint, mode)` snapshot for [`gamestream_admission`].
type LiveGs = (Option<[u8; 32]>, (u32, u32, u32));
/// The outcome of [`gamestream_admission`].
enum GsDecision {
/// Proceed with the launch (no live session, a same-client re-launch, or `steal`/`separate`
/// taking over the single session).
Serve,
/// Serve at the live session's mode (`join` — honest-downgrade).
Join((u32, u32, u32)),
/// Refuse with a 503 (`reject`).
Reject,
}
/// The GameStream single-session mode-conflict decision (Stage 4, pure so it's unit-tested). `live`
/// is the currently-live session's `(owner_fp, mode)` (`None` ⇒ no session live). No session or a
/// same-client re-launch ⇒ `Serve`; a DIFFERENT client launching applies `policy` — `reject` ⇒
/// `Reject`, `join` ⇒ `Join` the live mode, `steal`/`separate` (GameStream has no separate) ⇒ `Serve`
/// (take over the one session).
fn gamestream_admission(
live: Option<LiveGs>,
req_fp: Option<[u8; 32]>,
policy: crate::vdisplay::policy::ModeConflict,
) -> GsDecision {
use crate::vdisplay::policy::ModeConflict;
let Some((owner, mode)) = live else {
return GsDecision::Serve;
};
let different = match (owner, req_fp) {
(Some(o), Some(r)) => o != r,
_ => true, // unknown owner or anonymous requester → treat as a different client
};
if !different {
return GsDecision::Serve;
}
match policy {
ModeConflict::Reject => GsDecision::Reject,
ModeConflict::Join => GsDecision::Join(mode),
ModeConflict::Steal | ModeConflict::Separate => GsDecision::Serve,
}
}
fn session_url_xml(st: &AppState, tag: &str) -> String {
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<root status_code=\"200\">\n<sessionUrl0>rtsp://{}:{RTSP_PORT}</sessionUrl0>\n<{tag}>1</{tag}>\n</root>\n",
@@ -349,4 +436,43 @@ mod tests {
"a non-pinned cert stays rejected"
);
}
#[test]
fn gamestream_admission_policy_matrix() {
use crate::vdisplay::policy::ModeConflict;
let (a, b) = ([1u8; 32], [2u8; 32]);
let live = Some((Some(a), (2560, 1440, 120)));
// No live session → always Serve.
assert!(matches!(
gamestream_admission(None, Some(b), ModeConflict::Reject),
GsDecision::Serve
));
// Same-client re-launch → Serve regardless of policy.
assert!(matches!(
gamestream_admission(live, Some(a), ModeConflict::Reject),
GsDecision::Serve
));
// A DIFFERENT client applies the policy.
assert!(matches!(
gamestream_admission(live, Some(b), ModeConflict::Reject),
GsDecision::Reject
));
assert!(matches!(
gamestream_admission(live, Some(b), ModeConflict::Join),
GsDecision::Join((2560, 1440, 120))
));
assert!(matches!(
gamestream_admission(live, Some(b), ModeConflict::Steal),
GsDecision::Serve
));
assert!(matches!(
gamestream_admission(live, Some(b), ModeConflict::Separate),
GsDecision::Serve
));
// Anonymous requester (no cert presented) is treated as a different client.
assert!(matches!(
gamestream_admission(live, None, ModeConflict::Reject),
GsDecision::Reject
));
}
}
+201 -53
View File
@@ -286,13 +286,19 @@ fn open_gs_virtual_source(
std::sync::atomic::AtomicBool::new(false),
))
});
let vout = vd
.create(punktfunk_core::Mode {
let vout = crate::vdisplay::registry::acquire(
&mut vd,
punktfunk_core::Mode {
width: cfg.width,
height: cfg.height,
refresh_hz: cfg.fps,
})
.context("create virtual output at client resolution")?;
},
// GameStream's deliberate quit is the Moonlight "Quit App" (nvhttp `h_cancel`), not a QUIC
// close code — wiring it to skip-linger is a follow-up, so this path keeps normal keep-alive
// (a fresh, never-set flag).
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
)
.context("create virtual output at client resolution")?;
// HDR: pass the negotiated `cfg.hdr` (client asked for HDR AND the host can deliver it). On the
// Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10
// PQ stream flows even from an SDR desktop; an already-HDR desktop streams PQ regardless (the
@@ -397,6 +403,68 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
Ok(())
}
/// Pacing layout for one frame's `n` packets (`n >= 1`): `(chunk_size, steps)`. The chunk grows
/// with the frame so the number of paced bursts — each ending in a `thread::sleep` — never exceeds
/// `MAX_PACE_STEPS`. A fixed 16-packet chunk let the step count scale with bitrate (~38 for a
/// 4K/250Mbps frame's ~600 packets); the accumulated sub-ms sleep overshoot on the non-RT send
/// thread then blew the per-frame budget and backed the handoff queue up. Bounding the steps keeps
/// microburst shaping at low bitrate while making overshoot negligible and bitrate-independent.
fn pace_layout(n: usize) -> (usize, usize) {
const MIN_PACE_CHUNK: usize = 16;
const MAX_PACE_STEPS: usize = 12;
let chunk_sz = MIN_PACE_CHUNK.max(n.div_ceil(MAX_PACE_STEPS));
let steps = n.div_ceil(chunk_sz); // ≤ MAX_PACE_STEPS
(chunk_sz, steps)
}
/// One encoded frame handed from the encode loop to the packetizer thread: the frame's access
/// units (owned buffers, each with its frame type) plus the shared 90 kHz RTP timestamp. FEC
/// packetization runs on the packetizer thread — off the encode loop — so it never serializes
/// behind encode (measured ~3 ms/frame at 4K, which capped GameStream's frame rate well below what
/// the encoder alone can sustain).
struct RawFrame {
aus: Vec<(Vec<u8>, FrameType)>,
ts: u32,
}
/// Packetizer thread: turns each [`RawFrame`]'s access units into wire datagrams (data + ReedSolomon
/// FEC parity shards) via the stateful [`VideoPacketizer`], then hands the batch to the paced sender.
/// It sits between encode and send so the FEC never blocks the encode loop. Backpressure: the hand-off
/// to the sender BLOCKS, so if the paced sender falls behind, the packetizer stalls and the
/// encode→packetizer queue fills — the encode loop then drops the newest frame (see the loop) rather
/// than stalling. Tallies goodput (bytes handed to the wire) into `goodput` for the encode loop's stats
/// window. Exits when either neighbor's channel closes (session teardown / client gone).
fn spawn_packetizer(
rx: std::sync::mpsc::Receiver<RawFrame>,
tx: std::sync::mpsc::SyncSender<PacketBatch>,
mut pk: VideoPacketizer,
goodput: Arc<std::sync::atomic::AtomicU64>,
) -> Result<()> {
std::thread::Builder::new()
.name("punktfunk-pkt".into())
.spawn(move || {
// Above-normal, like the send thread — this stage is on the per-frame critical path.
crate::punktfunk1::boost_thread_priority(false);
while let Ok(frame) = rx.recv() {
let mut batch: PacketBatch = Vec::new();
for (au, ft) in frame.aus {
batch.extend(pk.packetize(&au, ft, frame.ts));
}
if batch.is_empty() {
continue;
}
let bytes: u64 = batch.iter().map(|p| p.len() as u64).sum();
// Blocking send: propagates the paced sender's backpressure upstream (see above).
if tx.send(batch).is_err() {
break; // sender exited (client gone)
}
goodput.fetch_add(bytes, std::sync::atomic::Ordering::Relaxed);
}
})
.context("spawn packetizer thread")?;
Ok(())
}
/// Dedicated send thread: one [`PacketBatch`] per frame arrives on `rx`; its packets go out in
/// `sendmmsg` chunks, paced so the frame's data spreads over ~3/4 of the frame interval
/// (microburst shaping at chunk granularity — a real link drops line-rate bursts; the encode
@@ -414,8 +482,14 @@ fn spawn_sender(
// Transmit thread: above-normal, matching the native path's send thread (includes the
// Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
crate::punktfunk1::boost_thread_priority(false);
// Chunk pacing: 16 packets per burst, bursts spread across the send budget.
const PACE_CHUNK: usize = 16;
// Chunk pacing: spread the frame's packets across the send budget in a BOUNDED number
// of bursts. A fixed 16-packet chunk made the burst count scale with bitrate (~38 for a
// 4K/250Mbps frame's ~600 packets), and each burst ends in a `thread::sleep`; on this
// non-RT send thread those sub-ms sleeps overshoot, and ~38 per frame blew the 12.5ms
// budget past the 16.67ms frame interval — backing the depth-2 handoff queue up and
// dropping ~half the frames ("send queue full"). Capping the step count keeps the
// microburst shaping (a real link drops line-rate bursts) while making per-frame sleep
// overshoot negligible and independent of bitrate.
let budget = frame_interval.mul_f32(0.75);
let mut rng = rand::thread_rng();
let mut sent: u64 = 0;
@@ -434,17 +508,21 @@ fn spawn_sender(
if n == 0 {
continue;
}
let per_chunk = budget.mul_f64((PACE_CHUNK as f64 / n as f64).min(1.0));
// Chunk size + step count, bounded so a high-bitrate frame doesn't fan out into
// dozens of sleeps. Each step gets an equal slice of the budget (total pacing time
// == budget regardless of n).
let (chunk_sz, steps) = pace_layout(n);
let per_step = budget.mul_f64(1.0 / steps as f64);
let start = Instant::now();
for (i, chunk) in batch.chunks(PACE_CHUNK).enumerate() {
for (i, chunk) in batch.chunks(chunk_sz).enumerate() {
if let Err(e) = sendmmsg_all(&sock, chunk) {
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
running.store(false, Ordering::SeqCst);
return;
}
sent += chunk.len() as u64;
// Sleep toward the next chunk's deadline; skip sub-500µs sleeps (jitter).
let target = start + per_chunk.mul_f64((i + 1) as f64);
// Sleep toward the next step's deadline; skip sub-500µs sleeps (jitter).
let target = start + per_step.mul_f64((i + 1) as f64);
if let Some(ahead) = target.checked_duration_since(Instant::now()) {
if ahead >= Duration::from_micros(500) {
std::thread::sleep(ahead);
@@ -518,7 +596,7 @@ fn stream_body(
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(20);
let mut pk = VideoPacketizer::new(cfg.packet_size, fec_pct, cfg.min_fec);
let pk = VideoPacketizer::new(cfg.packet_size, fec_pct, cfg.min_fec);
// Pace at the client's negotiated frame rate, re-encoding the last captured frame when the
// compositor produced no new one. Compositors only emit frames on damage, so a static or
@@ -538,9 +616,15 @@ fn stream_body(
let mut sent_batches: u64 = 0;
let mut dropped_batches: u64 = 0;
// The send thread: one frame's batch at a time over a small bounded queue. Depth 2 means a
// slow send can buffer one frame while the next encodes; beyond that the NEWEST batch is
// dropped (the client recovers via FEC/RFI) rather than ever stalling the encode loop.
// Three-stage pipeline so FEC packetization never blocks encode: `encode loop → [raw AUs] →
// packetizer (FEC/RS) → [wire batch] → paced sender`, each stage on its own thread joined by a
// depth-2 bounded queue. Depth 2 means a slow stage can buffer one frame while the next is
// produced; beyond that the NEWEST frame is dropped (the client recovers via FEC/RFI) rather than
// stalling the encode loop. Backpressure chains up: a slow sender blocks the packetizer, which
// fills the encode→packetizer queue, which makes the encode loop drop — encode itself never
// waits. Goodput (bytes handed to the wire) is tallied by the packetizer into `goodput`, read at
// the encode loop's 1 s stats boundary (the old inline batch-byte sum moved with packetization).
let goodput = Arc::new(std::sync::atomic::AtomicU64::new(0));
let (batch_tx, batch_rx) = std::sync::mpsc::sync_channel::<PacketBatch>(2);
spawn_sender(
sock.try_clone().context("clone video socket")?,
@@ -549,12 +633,14 @@ fn stream_body(
running.clone(),
drop_pct,
)?;
let (raw_tx, raw_rx) = std::sync::mpsc::sync_channel::<RawFrame>(2);
spawn_packetizer(raw_rx, batch_tx, pk, goodput.clone())?;
// Per-stage timing (PUNKTFUNK_PERF=1): max µs/stage per second + unique vs re-encoded frames,
// to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
let perf = crate::config::config().perf;
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) =
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32);
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut uniq) =
(0u128, 0u128, 0u128, 0u128, 0u32);
// Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors
// for p50/p99, the goodput bytes queued to the sender this window, the previous window's
// dropped-frame count for delta computation, and the registration id cached on the first sample.
@@ -566,7 +652,6 @@ fn stream_body(
let mut sid: Option<u32> = None;
let (mut v_cap, mut v_enc, mut v_pkt, mut v_send): (Vec<u32>, Vec<u32>, Vec<u32>, Vec<u32>) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
let mut bytes_win: u64 = 0;
let mut last_dropped_batches: u64 = 0;
// Absolute next-frame deadline — the single pacing clock for the loop.
let mut next_frame = Instant::now();
@@ -580,6 +665,22 @@ fn stream_body(
const MAX_REBUILDS: u32 = 5;
let mut rebuilds: u32 = 0;
// Coalesce forced keyframes. Under loss Moonlight spams IDR/RFI requests; on an encoder without
// RFI (VAAPI/AMD — `supports_rfi=false`) each one becomes a full IDR, so an un-coalesced request
// stream turns EVERY frame into a 4K IDR, saturates the send path, and collapses the session
// instead of recovering. One fresh IDR already resolves all pending loss, so after emitting one
// we ignore further keyframe requests for a short in-flight window (~2 frames). NVENC
// ref-invalidation (cheap, no IDR spike) is never rate-limited — only full keyframes are.
let keyframe_coalesce = frame_interval * 2;
let mut last_keyframe: Option<Instant> = None;
// A frame dropped at the pipeline head (below) breaks the reference chain for the following
// P-frames: the client never receives it, but the encoder advanced its references past it, and —
// packetization being downstream now — a dropped frame consumes no frameIndex for the client to
// detect the gap. So the host re-anchors itself: a drop arms a keyframe on the next iteration,
// routed through the same coalesce gate as client IDR requests so a burst of drops (congestion)
// can't become an IDR storm.
let mut recover_after_drop = false;
while running.load(Ordering::SeqCst) {
let tick = Instant::now();
// Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is
@@ -645,6 +746,7 @@ fn stream_body(
.context("reopen encoder after rebuild")?;
supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe();
last_keyframe = Some(Instant::now());
next_frame = Instant::now();
tracing::info!("gamestream: source rebuilt — stream continues");
continue;
@@ -654,58 +756,74 @@ fn stream_body(
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder
// re-references an older still-valid frame — no costly IDR spike); if the encoder can't
// invalidate (range too old, or no NVENC RFI) it returns false and we force a keyframe.
// A prior pipeline drop needs a fresh keyframe to re-anchor the reference chain (see below).
let mut want_keyframe = recover_after_drop;
recover_after_drop = false;
if let Some((first, last)) = rfi_range.lock().unwrap().take() {
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR
// spike); otherwise — or if the range is too old to invalidate — force a keyframe.
// spike); otherwise — or if the range is too old to invalidate — fall back to a keyframe.
if !(supports_rfi && enc.invalidate_ref_frames(first, last)) {
enc.request_keyframe();
want_keyframe = true;
}
}
// An explicit IDR request (or a rangeless RFI) forces a keyframe so the client resyncs
// An explicit IDR request (or a rangeless RFI) asks for a keyframe so the client resyncs
// immediately instead of waiting for the next GOP boundary.
if force_idr.swap(false, Ordering::SeqCst) {
enc.request_keyframe();
want_keyframe = true;
}
// Coalesce: emit at most one forced keyframe per in-flight window, so a burst of recovery
// requests during one loss event doesn't turn every frame into a full IDR (see above).
if want_keyframe {
let now = Instant::now();
let emit = match last_keyframe {
Some(t) => now.duration_since(t) >= keyframe_coalesce,
None => true,
};
if emit {
enc.request_keyframe();
last_keyframe = Some(now);
} else {
tracing::debug!("video: keyframe request coalesced (IDR still in flight)");
}
}
enc.submit(&frame).context("encoder submit")?;
let t_enc = tick.elapsed();
// 90 kHz RTP timestamp from wall-clock, so a variable capture rate stays correct.
let ts = (stream_start.elapsed().as_secs_f64() * 90_000.0) as u32;
let mut batch: Vec<Vec<u8>> = Vec::new();
// Drain the encoder's access units (owned buffers) — FEC/packetization runs on the
// packetizer thread, off this loop, so it never serializes behind encode.
let mut aus: Vec<(Vec<u8>, FrameType)> = Vec::new();
while let Some(au) = enc.poll().context("encoder poll")? {
let ft = if au.keyframe {
FrameType::Idr
} else {
FrameType::P
};
batch.extend(pk.packetize(&au.data, ft, ts));
aus.push((au.data, ft));
}
let t_pkt = tick.elapsed();
// Hand the frame's packets to the send thread; never block here. A full queue means
// the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding.
let n = batch.len();
// Goodput this window = bytes actually queued to the sender (a dropped batch never reaches
// the wire, so it's excluded). Summed only when measuring, to keep the idle path free.
let batch_bytes: u64 = if measure {
batch.iter().map(|p| p.len() as u64).sum()
} else {
0
};
if n > 0 {
match batch_tx.try_send(batch) {
// Hand the frame's AUs to the pipeline; never block here. A full queue means the pipeline
// (packetizer, or the paced sender behind it) is behind — drop this frame (FEC/RFI covers the
// client) and keep encoding, so a downstream stall can never cap the encode rate.
if !aus.is_empty() {
match raw_tx.try_send(RawFrame { aus, ts }) {
Ok(()) => {
sent_batches += 1;
bytes_win += batch_bytes;
}
Err(std::sync::mpsc::TrySendError::Full(_)) => {
dropped_batches += 1;
recover_after_drop = true; // re-anchor the reference chain on the next frame
if dropped_batches.is_power_of_two() {
tracing::warn!(dropped_batches, "video: send queue full — frame dropped");
tracing::warn!(
dropped_batches,
"video: pipeline queue full — frame dropped"
);
}
}
Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
break; // sender exited (client gone)
break; // packetizer/sender exited (client gone)
}
}
}
@@ -713,26 +831,33 @@ fn stream_body(
let t_send = tick.elapsed();
let cap_us = t_cap.as_micros();
let enc_us = (t_enc - t_cap).as_micros();
let pkt_us = (t_pkt - t_enc).as_micros();
let send_us = (t_send - t_pkt).as_micros();
// `poll` = drain the encoder's AUs; `enqueue` = hand-off to the pipeline. FEC/packetize
// and the paced send now run on their own threads, off this loop — so both of these
// should be small; if they aren't, the encode loop is being stalled by pipeline
// backpressure (a full queue), which is the signal that a downstream stage can't keep up.
let poll_us = (t_pkt - t_enc).as_micros();
let enqueue_us = (t_send - t_pkt).as_micros();
mx_cap = mx_cap.max(cap_us);
mx_enc = mx_enc.max(enc_us);
mx_pkt = mx_pkt.max(pkt_us);
mx_send = mx_send.max(send_us);
mx_pkts = mx_pkts.max(n);
mx_pkt = mx_pkt.max(poll_us);
mx_send = mx_send.max(enqueue_us);
v_cap.push(cap_us as u32);
v_enc.push(enc_us as u32);
v_pkt.push(pkt_us as u32);
v_send.push(send_us as u32);
v_pkt.push(poll_us as u32);
v_send.push(enqueue_us as u32);
}
fps_count += 1;
if fps_t.elapsed() >= Duration::from_secs(1) {
let secs = fps_t.elapsed().as_secs_f64();
// Bytes handed to the wire this window, tallied by the packetizer thread (goodput).
let win_bytes = goodput.swap(0, std::sync::atomic::Ordering::Relaxed);
if perf {
// Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device
// copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new
// captured frames (vs re-encoded). `pkts`=max packets in one frame (IDR spike).
// Max µs/stage this second on the ENCODE loop: cap=drain channel, enc=submit
// (zero-copy device copy + NVENC), pkt=poll (AU drain), send=enqueue to the pipeline.
// FEC/packetize and the paced send run on their own threads now, so pkt/send here
// should be near-zero — a nonzero value means encode is being stalled by pipeline
// backpressure. `uniq`=new captured frames (vs re-encoded).
tracing::info!(
fps = fps_count,
uniq,
@@ -740,7 +865,6 @@ fn stream_body(
pkt_us = mx_pkt,
send_us = mx_send,
cap_us = mx_cap,
max_pkts = mx_pkts,
"video: streaming (perf)"
);
} else {
@@ -753,7 +877,7 @@ fn stream_body(
}
// Web-console capture: build the aggregated sample. The host send side exposes no
// receiver-side packet loss / FEC-recovery / send-buffer EAGAIN counters, so those stay
// 0 (not fabricated); `frames_dropped` is the per-frame send-queue overflow delta.
// 0 (not fabricated); `frames_dropped` is the per-frame pipeline-queue overflow delta.
if stats.is_armed() {
let session_id = *sid.get_or_insert_with(|| {
stats.register_session(
@@ -792,7 +916,7 @@ fn stream_body(
],
fps: (uniq as f64 / secs) as f32,
repeat_fps: (fps_count.saturating_sub(uniq) as f64 / secs) as f32,
mbps: (bytes_win as f64 * 8.0 / secs / 1_000_000.0) as f32,
mbps: (win_bytes as f64 * 8.0 / secs / 1_000_000.0) as f32,
bitrate_kbps: cfg.bitrate_kbps,
frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32,
packets_dropped: 0,
@@ -805,13 +929,11 @@ fn stream_body(
mx_enc = 0;
mx_pkt = 0;
mx_send = 0;
mx_pkts = 0;
uniq = 0;
v_cap.clear();
v_enc.clear();
v_pkt.clear();
v_send.clear();
bytes_win = 0;
last_dropped_batches = dropped_batches;
fps_count = 0;
fps_t = Instant::now();
@@ -889,4 +1011,30 @@ mod tests {
assert_eq!(got, 3 * PER_FRAME);
assert!(running.load(Ordering::SeqCst), "no spurious client-gone");
}
/// The pacing layout bounds the paced-burst (and thus sleep) count regardless of frame size,
/// while always covering every packet and keeping small frames on the 16-packet floor. Guards
/// the 4K/high-bitrate "send queue full" regression (a fixed 16-packet chunk fanned a ~600
/// packet frame into ~38 sleeps, whose overshoot blew the per-frame send budget).
#[test]
fn pace_layout_bounds_step_count() {
for &n in &[1usize, 16, 146, 610, 1024, 5000, 50_000] {
let (chunk, steps) = pace_layout(n);
assert!(steps >= 1, "n={n}: at least one step");
assert!(steps <= 12, "n={n}: step count {steps} exceeded the cap");
assert!(
chunk >= 16,
"n={n}: chunk {chunk} below the 16-packet floor"
);
assert!(
chunk * steps >= n,
"n={n}: {chunk}×{steps} must cover all packets"
);
}
// Small frames stay on the floor: one 16-packet burst.
assert_eq!(pace_layout(1), (16, 1));
assert_eq!(pace_layout(16), (16, 1));
// A 4K/250Mbps frame (~600 packets) was ~38 bursts at a fixed 16 — now bounded.
assert!(pace_layout(610).1 <= 12);
}
}
+37
View File
@@ -418,6 +418,20 @@ fn real_main() -> Result<()> {
allow_pairing: true,
pairing_pin: None,
paired_store: None,
// Fixed data-plane port: bind it and stream direct (no hole-punch), removing the
// ~2.5 s punch-timeout on a firewalled host. Default (absent) = a random port +
// hole-punch. Also honors PUNKTFUNK_DATA_PORT.
data_port: get("--data-port")
.map(str::to_string)
.or_else(|| std::env::var("PUNKTFUNK_DATA_PORT").ok())
.and_then(|s| s.parse().ok()),
// Disconnect-detection latency (QUIC control-connection idle timeout): --idle-timeout-ms
// overrides PUNKTFUNK_IDLE_TIMEOUT_MS; absent = the core default (8s).
idle_timeout: get("--idle-timeout-ms")
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&ms| ms > 0)
.map(std::time::Duration::from_millis)
.or_else(punktfunk1::idle_timeout_from_env),
})
}
// Windows service control: install/uninstall/start/stop/status + the SCM `run` entry point.
@@ -501,6 +515,13 @@ fn input_test() -> Result<()> {
fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> {
let mut opts = mgmt::Options::default();
let mut native_port: u16 = 9777; // the native plane always runs now
// Fixed data-plane UDP port: `Some(p)` binds p and streams direct (no hole-punch, no ~2.5 s
// punch-timeout on a firewalled host); `None` (default) = a random port + hole-punch. Env
// default, `--data-port` overrides.
let mut data_port: Option<u16> = std::env::var("PUNKTFUNK_DATA_PORT")
.ok()
.and_then(|s| s.parse().ok());
let mut open = false;
let mut gamestream = false;
// Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so
@@ -541,6 +562,13 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
.parse()
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))?
}
"--data-port" => {
data_port = Some(
next()?
.parse()
.map_err(|_| anyhow::anyhow!("bad --data-port (want a port number)"))?,
)
}
// Opt into the GameStream/Moonlight-compat planes (off by default — they carry the
// inherent on-path #5/#9 weaknesses; only for a trusted LAN).
"--gamestream" | "--moonlight" => gamestream = true,
@@ -576,6 +604,7 @@ fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServ
// Advertise the mgmt port over mDNS so clients learn where to browse the library (rather than
// assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
mgmt_port: opts.bind.port(),
data_port,
};
Ok((opts, native, gamestream))
}
@@ -703,6 +732,10 @@ SERVE OPTIONS:
reuse, security-review #5/#9); enable only on a TRUSTED LAN
--native no-op (the native punktfunk/1 plane always runs in `serve` now)
--native-port <PORT> native QUIC port (default 9777)
--data-port <PORT> pin the per-session video data plane to this fixed UDP port and
stream direct (no hole-punch) — open exactly this port in a host
firewall to avoid the ~2.5 s punch-timeout. Default (unset) or
PUNKTFUNK_DATA_PORT: a random port + hole-punch (crosses NAT)
--open disable mandatory native pairing (default: pairing REQUIRED —
an open host any LAN device can stream from is insecure)
@@ -714,6 +747,10 @@ PUNKTFUNK1-HOST OPTIONS:
--max-sessions <N> exit after N sessions; 0 = serve forever (default: 0)
--max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
in the accept queue; 0 = unlimited (default: 4)
--data-port <PORT> pin the video data plane to this fixed UDP port and stream direct
(no hole-punch; open exactly this port to skip the ~2.5 s punch-
timeout). Default or PUNKTFUNK_DATA_PORT: random port + hole-punch.
A fixed port fits one session; concurrent ones fall back to random
--allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise
pair=optional. Default: pairing REQUIRED — the host rejects
unpaired clients and logs a 4-digit pairing PIN at startup;
+383
View File
@@ -156,6 +156,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(list_compositors))
.routes(routes!(list_gpus))
.routes(routes!(set_gpu_preference))
.routes(routes!(get_display_settings))
.routes(routes!(set_display_settings))
.routes(routes!(get_display_state))
.routes(routes!(release_display))
.routes(routes!(set_display_layout))
.routes(routes!(get_status))
.routes(routes!(get_local_summary))
.routes(routes!(list_paired_clients))
@@ -210,6 +215,7 @@ pub fn openapi_json() -> String {
tags(
(name = "host", description = "Host identity, capabilities, and liveness"),
(name = "gpu", description = "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use"),
(name = "display", description = "Virtual-display management policy: lifecycle (keep-alive), topology (primary/exclusive), conflict handling, identity, and layout"),
(name = "clients", description = "Paired Moonlight client management"),
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"),
(name = "native", description = "Native punktfunk/1 pairing: arm a window, display the host PIN, manage paired devices"),
@@ -376,6 +382,10 @@ struct LocalSummary {
pin_pending: bool,
/// Native pairing knocks awaiting the operator's approval (count only).
pending_approvals: u32,
/// Virtual displays being KEPT with no live session — lingering (keep-alive window) or pinned
/// (`keep_alive: forever`). Non-zero means a display (and, exclusive, your physical monitors) is
/// held; the tray surfaces it + a one-click release. Active (in-use) displays are not counted.
kept_displays: u32,
}
/// A paired (certificate-pinned) Moonlight client.
@@ -954,6 +964,304 @@ async fn set_gpu_preference(ApiJson(req): ApiJson<SetGpuPreference>) -> Response
Json(gpu_state()).into_response()
}
// ---------------------------------------------------------------------------------------
// Display management (design/display-management.md)
// ---------------------------------------------------------------------------------------
/// One preset's human-facing description + the fields it expands to, so the console can render a
/// preset picker with an accurate "what this does" preview without hardcoding the expansion.
#[derive(Serialize, ToSchema)]
struct PresetInfo {
/// The preset id (`default` | `gaming-rig` | `shared-desktop` | `hotdesk` | `workstation`).
id: String,
/// One-line story shown next to the option.
summary: String,
/// The effective policy this preset expands to (the same fields a `custom` policy carries).
fields: crate::vdisplay::policy::EffectivePolicy,
}
/// Full display-management state for the console: the stored policy, every preset's expansion, the
/// resolved effective policy, and which options this build actually enforces yet (Stage 0 wires
/// keep-alive linger + topology; the rest are stored but not yet acted on).
#[derive(Serialize, ToSchema)]
struct DisplaySettingsState {
/// The stored policy (preset + custom fields), or the built-in default when unconfigured.
settings: crate::vdisplay::policy::DisplayPolicy,
/// True once a `display-settings.json` exists (the console has configured this host).
configured: bool,
/// The effective (preset-expanded) policy currently in force.
effective: crate::vdisplay::policy::EffectivePolicy,
/// Every named preset and what it expands to (for the picker's preview).
presets: Vec<PresetInfo>,
/// Option names this build enforces right now. All five axes are now acted on (keep_alive +
/// topology since Stage 0-2, identity Stage 3, mode_conflict Stage 4, layout Stage 5) — the console
/// reads this to know which controls are live vs. "coming soon" (per-backend nuance, e.g. layout
/// position apply being KWin-only, is reported per display in `/display/state`).
enforced: Vec<String>,
}
fn preset_summary(id: &str) -> &'static str {
match id {
"default" => "Today's behavior: a short linger absorbs reconnects, the streamed output is the sole desktop, extra clients get their own view.",
"gaming-rig" => "Dedicated couch/headless box: the game and its display survive disconnects; whoever connects takes the box over.",
"shared-desktop" => "A desktop you also use in person: never blank the real monitors, never keep ghost displays, concurrent viewers each get a view.",
"hotdesk" => "One user at a time with fast reattach; a second user is told the box is busy; each device+resolution keeps its own scaling.",
"workstation" => "Multi-monitor daily driver: your displays come back exactly where you arranged them, per-client identity, exclusive.",
_ => "",
}
}
fn display_settings_state() -> DisplaySettingsState {
use crate::vdisplay::policy::{self, Preset};
let store = policy::prefs();
let settings = store.get();
let configured = store.configured().is_some();
let presets = [
("default", Preset::Default),
("gaming-rig", Preset::GamingRig),
("shared-desktop", Preset::SharedDesktop),
("hotdesk", Preset::Hotdesk),
("workstation", Preset::Workstation),
]
.into_iter()
.filter_map(|(id, p)| {
policy::preset_fields(p).map(|e| PresetInfo {
id: id.to_string(),
summary: preset_summary(id).to_string(),
fields: e,
})
})
.collect();
DisplaySettingsState {
effective: settings.effective(),
settings,
configured,
presets,
enforced: vec![
"keep_alive".into(),
"topology".into(),
"mode_conflict".into(),
"identity".into(),
"layout".into(),
],
}
}
/// Display-management policy
///
/// The stored virtual-display policy (lifecycle, topology, conflict handling, identity, layout),
/// every preset's expansion, and which options this build enforces yet. See
/// `design/display-management.md`.
#[utoipa::path(
get,
path = "/display/settings",
tag = "display",
operation_id = "getDisplaySettings",
responses(
(status = OK, description = "Stored policy + preset expansions + enforced options", body = DisplaySettingsState),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn get_display_settings() -> Json<DisplaySettingsState> {
Json(display_settings_state())
}
/// Set the display-management policy
///
/// Persists a new policy (validated + clamped) and applies it from the next connect/teardown — a
/// running session keeps the display it opened on. `keep_alive: forever` (the gaming-rig preset) is
/// honored (the display is Pinned; free it via `POST /display/release`).
#[utoipa::path(
put,
path = "/display/settings",
tag = "display",
operation_id = "setDisplaySettings",
request_body = crate::vdisplay::policy::DisplayPolicy,
responses(
(status = OK, description = "Policy stored; the new state", body = DisplaySettingsState),
(status = BAD_REQUEST, description = "Malformed policy body", body = ApiError),
(status = INTERNAL_SERVER_ERROR, description = "Policy could not be persisted", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn set_display_settings(
ApiJson(policy): ApiJson<crate::vdisplay::policy::DisplayPolicy>,
) -> Response {
// `keep_alive: forever` (the gaming-rig preset) is now honored: the display is Pinned (Linux
// registry + Windows `MgrState::Pinned`) and freed via `POST /display/release` (the escape hatch).
if let Err(e) = crate::vdisplay::policy::prefs().set(policy) {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("persist display policy: {e:#}"),
);
}
tracing::info!("management API: display policy updated");
Json(display_settings_state()).into_response()
}
/// One live or kept virtual display.
#[derive(Serialize, ToSchema)]
struct ApiDisplayInfo {
/// Stable-enough id for the `/display/release` `slot` argument.
slot: u64,
/// Backend name (`pf-vdisplay`, `kwin`, …).
backend: String,
/// `WIDTHxHEIGHT@HZ`.
mode: String,
/// `active` | `lingering` | `pinned`.
state: String,
/// Milliseconds until a lingering display is torn down (absent when active/pinned).
expires_in_ms: Option<u64>,
/// Live sessions holding the display.
sessions: u32,
/// Short client label, when the owner tracks it.
client: Option<String>,
/// Display group (shared desktop) id — several displays with the same group form one desktop (§6A).
group: u32,
/// This display's ordinal within its group, in acquire order (0-based).
display_index: u32,
/// Desktop-space top-left `x` (auto-row or the console's manual arrangement, §6.2).
x: i32,
/// Desktop-space top-left `y`.
y: i32,
/// Stable per-client identity slot keying persistent config + manual layout (absent = shared/anonymous).
identity_slot: Option<u32>,
/// Effective topology for this display's group (`extend` | `primary` | `exclusive`).
topology: String,
}
/// The host's managed virtual displays right now.
#[derive(Serialize, ToSchema)]
struct DisplayStateResponse {
displays: Vec<ApiDisplayInfo>,
}
/// Request body for `releaseDisplay`.
#[derive(Deserialize, ToSchema)]
struct ReleaseDisplayRequest {
/// Slot to release (see `state`); omit to release **all** kept displays.
#[serde(default)]
slot: Option<u64>,
}
/// Result of a `/display/release`.
#[derive(Serialize, ToSchema)]
struct ReleaseDisplayResult {
/// Number of kept displays torn down.
released: usize,
}
/// Live virtual displays
///
/// The host's managed virtual displays right now — active (streaming), lingering (kept after
/// disconnect, counting down to teardown), or pinned (kept indefinitely). See
/// `design/display-management.md`.
#[utoipa::path(
get,
path = "/display/state",
tag = "display",
operation_id = "getDisplayState",
responses(
(status = OK, description = "The live/kept virtual displays", body = DisplayStateResponse),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn get_display_state() -> Json<DisplayStateResponse> {
let snap = crate::vdisplay::registry::snapshot();
Json(DisplayStateResponse {
displays: snap
.displays
.into_iter()
.map(|d| ApiDisplayInfo {
slot: d.slot,
backend: d.backend,
mode: format!("{}x{}@{}", d.mode.0, d.mode.1, d.mode.2),
state: d.state,
expires_in_ms: d.expires_in_ms,
sessions: d.sessions,
client: d.client,
group: d.group,
display_index: d.display_index,
x: d.position.0,
y: d.position.1,
identity_slot: d.identity_slot,
topology: d.topology,
})
.collect(),
})
}
/// Release kept virtual displays
///
/// Tear down lingering/pinned displays now — so a physical-screen user gets their screen back
/// without waiting out the linger. `slot` releases one; omit it to release all kept displays.
/// Active (streaming) displays are never torn down here (that is session control).
#[utoipa::path(
post,
path = "/display/release",
tag = "display",
operation_id = "releaseDisplay",
request_body = ReleaseDisplayRequest,
responses(
(status = OK, description = "The number of kept displays released", body = ReleaseDisplayResult),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn release_display(
ApiJson(req): ApiJson<ReleaseDisplayRequest>,
) -> Json<ReleaseDisplayResult> {
let released = crate::vdisplay::registry::release(req.slot);
tracing::info!(slot = ?req.slot, released, "management API: display release");
Json(ReleaseDisplayResult { released })
}
/// Request body for `setDisplayLayout`: per-identity-slot desktop offsets, keyed by the identity-slot
/// id as a string (the same id `/display/state` reports as `identity_slot`).
#[derive(Deserialize, ToSchema)]
struct DisplayLayoutRequest {
/// `{"<identity_slot>": {"x": …, "y": …}}` — where each arranged display's top-left sits.
#[serde(default)]
positions: std::collections::BTreeMap<String, crate::vdisplay::policy::Position>,
}
/// Arrange virtual displays
///
/// Set the **manual** desktop arrangement — per-identity-slot `(x, y)` offsets so a multi-monitor
/// group (§6A/§6B) comes back where the operator placed it. Persisted into the policy's layout block
/// and switched to manual mode; applied from the next connect (a live group re-applies on its next
/// acquire). Locks in the current effective behavior as explicit fields, so arranging displays never
/// silently changes keep-alive/topology/conflict/identity. See `design/display-management.md` §6.2.
#[utoipa::path(
put,
path = "/display/layout",
tag = "display",
operation_id = "setDisplayLayout",
request_body = DisplayLayoutRequest,
responses(
(status = OK, description = "Layout stored; the new settings state", body = DisplaySettingsState),
(status = INTERNAL_SERVER_ERROR, description = "Layout could not be persisted", body = ApiError),
(status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError),
)
)]
async fn set_display_layout(ApiJson(req): ApiJson<DisplayLayoutRequest>) -> Response {
let store = crate::vdisplay::policy::prefs();
// Lock the current effective behavior into explicit fields + set the manual arrangement (pure
// transform, unit-tested in `policy.rs`) — so arranging displays is orthogonal to the other policy
// axes. (`effective` keep_alive is never `Forever` via the API — the settings PUT rejects it.)
let policy = store.get().effective().with_manual_layout(req.positions);
if let Err(e) = store.set(policy) {
return api_error(
StatusCode::INTERNAL_SERVER_ERROR,
&format!("persist display layout: {e:#}"),
);
}
tracing::info!(
positions = display_settings_state().settings.layout.positions.len(),
"management API: display layout updated"
);
Json(display_settings_state()).into_response()
}
/// Live host status
#[utoipa::path(
get,
@@ -1026,6 +1334,11 @@ async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummar
native_paired_clients,
pin_pending: st.app.pairing.pin.awaiting_pin(),
pending_approvals,
kept_displays: crate::vdisplay::registry::snapshot()
.displays
.iter()
.filter(|d| d.state == "lingering" || d.state == "pinned")
.count() as u32,
})
}
@@ -2257,6 +2570,7 @@ mod tests {
fps: 120,
appid: 1,
peer_ip: None,
owner_fp: None,
});
state.streaming.store(true, Ordering::SeqCst);
@@ -2383,6 +2697,7 @@ mod tests {
fps: 60,
appid: 1,
peer_ip: None,
owner_fp: None,
});
let del = axum::http::Request::delete("/api/v1/session")
@@ -2473,6 +2788,74 @@ mod tests {
.unwrap()
}
/// The display-management GET surface (presets + effective + the enforced-axes list). READ-ONLY
/// on purpose: `prefs()` is a process-global `OnceLock`, so a PUT here would clobber it and race
/// other tests running in the same process. `keep_alive: forever` (gaming-rig) is now accepted
/// (not rejected) — that acceptance is covered on-glass (`.116`) + by the pure `policy` tests, and
/// the `forever` value is read off the surfaced preset below without writing.
#[tokio::test]
async fn display_settings_surface() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/display/settings")).await;
assert_eq!(status, StatusCode::OK);
let presets = body["presets"].as_array().expect("presets array");
assert_eq!(
presets.len(),
5,
"all five named presets are surfaced for the console picker"
);
assert!(
body["effective"]["keep_alive"].is_object(),
"the effective policy is echoed"
);
// gaming-rig surfaces keep_alive: forever (no longer rejected) — read it off the preset list.
let gaming = presets
.iter()
.find(|p| p["id"] == "gaming-rig")
.expect("gaming-rig preset surfaced");
assert_eq!(
gaming["fields"]["keep_alive"]["mode"], "forever",
"gaming-rig is keep_alive: forever"
);
let enforced: Vec<&str> = body["enforced"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
// All five axes are enforced now (Stages 0-5).
assert!(enforced.contains(&"keep_alive"));
assert!(enforced.contains(&"topology"));
assert!(enforced.contains(&"mode_conflict"));
assert!(enforced.contains(&"identity"));
assert!(enforced.contains(&"layout"));
}
/// The display state/release endpoints are wired + auth-gated. On the test host no backend has
/// created a display (and non-Windows reports none), so `/state` is empty and `/release` is a
/// no-op — the shapes + the "nothing to release" path, without touching any global owner.
#[tokio::test]
async fn display_state_and_release_empty() {
let app = test_app(test_state(), None);
let (status, body) = send(&app, get_req("/api/v1/display/state")).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(
body["displays"].as_array().map(|a| a.len()),
Some(0),
"no managed displays on an idle test host"
);
let (status, body) = send(
&app,
post_json("/api/v1/display/release", serde_json::json!({})),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["released"], 0);
}
#[tokio::test]
async fn native_pairing_arm_show_and_unpair() {
let np = Arc::new(
+279 -26
View File
@@ -75,6 +75,41 @@ pub struct Punktfunk1Options {
pub pairing_pin: Option<String>,
/// Paired-clients store path override (tests); `None` = the default config path.
pub paired_store: Option<std::path::PathBuf>,
/// Fixed data-plane UDP port. `None`/`Some(0)` (default): bind a random ephemeral port and
/// **hole-punch** — wait ~2.5 s for the client's punch, then fall back to its reported address
/// (traverses NAT / a stateful inter-VLAN firewall with no forwarded port, at the cost of the
/// punch-timeout on a firewall that drops the punch). `Some(p)`: bind that fixed port and
/// stream **directly** to the client's reported address with no punch-wait — for a host whose
/// data port is fixed + firewall-opened/forwarded, this removes the punch-timeout delay. A
/// fixed port only fits one data plane at a time, so a concurrent session finding it busy
/// falls back to random + hole-punch (see [`bind_data_socket`]).
pub data_port: Option<u16>,
/// Control-connection idle timeout — the **disconnect-detection latency** (how long a vanished
/// client takes to be declared dead, which bounds how fast a dropped session tears down / lingers
/// and thus the reconnect-overlap window). `None` = the core default (8s). Set from
/// `PUNKTFUNK_IDLE_TIMEOUT_MS`; clamped to a ≥1s floor with a keep-alive that scales to it so a
/// live session never false-closes.
pub idle_timeout: Option<std::time::Duration>,
}
/// Bind the per-session data-plane UDP socket, honoring [`Punktfunk1Options::data_port`]. Returns
/// `(socket, direct)`: `direct = true` (a successfully-bound fixed port) means "stream straight to
/// the client's reported address, no hole-punch"; `false` (random port, or a busy fixed port) means
/// "hole-punch". The socket is held from the handshake through streaming — no drop-then-rebind
/// window in which a concurrent session could steal a fixed port.
fn bind_data_socket(data_port: Option<u16>) -> std::io::Result<(std::net::UdpSocket, bool)> {
if let Some(p) = data_port.filter(|p| *p != 0) {
match std::net::UdpSocket::bind(("0.0.0.0", p)) {
Ok(sock) => return Ok((sock, true)),
Err(e) => tracing::warn!(
data_port = p,
error = %e,
"fixed --data-port is busy (a concurrent session already holds it?) — \
falling back to a random port + hole-punch for this session"
),
}
}
Ok((std::net::UdpSocket::bind("0.0.0.0:0")?, false))
}
/// The native (punktfunk/1) trust store + on-demand arming PIN, shared with the management API.
@@ -143,6 +178,9 @@ pub(crate) struct NativeServe {
/// The management API's TCP port, advertised over mDNS so a client browses the game library on
/// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
pub mgmt_port: u16,
/// Fixed data-plane UDP port (`--data-port` / `PUNKTFUNK_DATA_PORT`); see
/// [`Punktfunk1Options::data_port`]. `None` = random port + hole-punch (the default).
pub data_port: Option<u16>,
}
/// Options for the native host when the unified `serve --native` runs it: real virtual capture,
@@ -153,6 +191,17 @@ pub(crate) struct NativeServe {
/// overflow clients wait in the accept queue. Override with `--max-concurrent`.
pub(crate) const DEFAULT_MAX_CONCURRENT: usize = 4;
/// The control-connection idle timeout (disconnect-detection latency) from
/// `PUNKTFUNK_IDLE_TIMEOUT_MS`; `None` (unset/invalid/zero) = the core default (8s). Clamped
/// downstream to a ≥1s floor with a keep-alive that scales to it, so a live session never false-closes.
pub(crate) fn idle_timeout_from_env() -> Option<std::time::Duration> {
std::env::var("PUNKTFUNK_IDLE_TIMEOUT_MS")
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|&ms| ms > 0)
.map(std::time::Duration::from_millis)
}
pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
Punktfunk1Options {
port: cfg.port,
@@ -165,6 +214,8 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
allow_pairing: false,
pairing_pin: None,
paired_store: None,
data_port: cfg.data_port,
idle_timeout: idle_timeout_from_env(),
}
}
@@ -178,10 +229,11 @@ pub(crate) async fn serve(
.context("load host identity (~/.config/punktfunk)")?;
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
.map_err(|e| anyhow!("cert fingerprint: {e}"))?;
let ep = endpoint::server_with_identity(
let ep = endpoint::server_with_identity_idle(
([0, 0, 0, 0], opts.port).into(),
&identity.cert_pem,
&identity.key_pem,
opts.idle_timeout.unwrap_or(endpoint::DEFAULT_IDLE_TIMEOUT),
)
.map_err(|e| anyhow!("QUIC server endpoint: {e}"))?;
tracing::info!(
@@ -341,6 +393,18 @@ pub(crate) async fn serve(
/// connects and never finishes the handshake would otherwise wedge the host for everyone.
const HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
/// QUIC application error code the host closes with on a `mode_conflict = reject` admission refusal,
/// carrying the human-readable busy reason (live mode + client label) the client surfaces. A distinct
/// code lets a client tell "host busy" apart from a transport failure.
const REJECT_BUSY_CODE: u32 = 0x42;
/// QUIC application error code a client closes with on a **deliberate quit** (a user "stop", not a
/// network drop). The host reads it off the connection's `ApplicationClosed` reason and tears the
/// session's virtual display down IMMEDIATELY, skipping the keep-alive linger — an unwanted disconnect
/// (idle timeout / reset / any other code) still lingers so a reconnect can resume. Shared with the
/// clients via `punktfunk_core::quic::QUIT_CLOSE_CODE`.
const QUIT_CODE: u32 = punktfunk_core::quic::QUIT_CLOSE_CODE;
/// Encoder bitrate (kbps) the host falls back to when the client expresses no preference
/// (`Hello::bitrate_kbps == 0`) — the long-standing 20 Mbps default. A client that knows its
/// link (e.g. after a speed test) requests an explicit rate instead.
@@ -651,8 +715,9 @@ async fn serve_session(
let source = opts.source;
let frames = opts.frames;
let data_port = opts.data_port;
let handshake = async {
let hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
let mut hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!(
hello.abi_version == punktfunk_core::WIRE_VERSION,
"wire version mismatch: client {} host {}",
@@ -684,6 +749,74 @@ async fn serve_session(
"video codec negotiated"
);
// Mode-conflict ADMISSION (Stage 4): a DIFFERENT client connecting while another client's
// session is live is resolved by the `mode_conflict` policy BEFORE the Welcome — `separate`
// (default, no change), `join` (serve at the live mode — an honest downgrade the client
// renders from the Welcome), `steal` (preempt the victim), or `reject` (refuse the handshake).
// A same-client reconnect never conflicts. THIS session registers in the live set once its
// data plane is up (below the handshake), so a later client can see + steal it.
{
use crate::vdisplay::admission::{admit, preempt_same_identity, Admission};
let peer_fp = endpoint::peer_fingerprint(&conn);
// Same-client RECONNECT preempt (design §5.3 "preempts downstream"): if THIS client
// already has a live session, it's the zombie of an unwanted disconnect whose QUIC idle
// timer hasn't fired yet (detection lags a drop by up to `max_idle_timeout`). Signal it to
// stop and give it the release grace so it tears its display down — which, keep-alive on,
// lingers — and THIS reconnect REUSES that kept display below instead of landing on a
// fresh SECOND one. Independent of the mode_conflict arm (it's our OWN prior session, not
// a conflict with a different client), and it runs before we register ourselves so we
// never signal our own stop flag.
let own_zombies = preempt_same_identity(peer_fp);
if !own_zombies.is_empty() {
tracing::info!(
count = own_zombies.len(),
"reconnect: preempting this client's own zombie session(s) so the kept display is reused"
);
for z in &own_zombies {
z.store(true, Ordering::SeqCst);
}
// Same blind release grace the steal path uses — lets the zombie's loops notice the
// stop flag and drop its display (→ Lingering) before we acquire below.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
match admit(peer_fp) {
Admission::Separate => {}
Admission::Join(m) => {
tracing::info!(
requested =
%format_args!("{}x{}@{}", hello.mode.width, hello.mode.height, hello.mode.refresh_hz),
live = %format_args!("{}x{}@{}", m.0, m.1, m.2),
"mode-conflict: JOIN — admitting at the live display's mode"
);
hello.mode.width = m.0;
hello.mode.height = m.1;
hello.mode.refresh_hz = m.2;
}
Admission::Steal(victims) => {
tracing::info!(
victims = victims.len(),
"mode-conflict: STEAL — preempting the live session(s)"
);
for v in &victims {
v.store(true, Ordering::SeqCst);
}
// Give the victims the release grace to tear their display down before we acquire.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
}
Admission::Reject(reason) => {
tracing::warn!("mode-conflict: REJECT — {reason}");
// Deliver the reason to the client as a TYPED refusal: close the QUIC connection
// with the BUSY application code + the reason bytes, which the client reads from
// the `ApplicationClosed` error (so its UI can say "host is streaming X to <name>")
// instead of seeing a bare connection drop. Then end the handshake.
conn.close(REJECT_BUSY_CODE.into(), reason.as_bytes());
anyhow::bail!("{reason}");
}
}
}
crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height)
.context("client-requested mode")?;
@@ -797,10 +930,12 @@ async fn serve_session(
"encode chroma"
);
// Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport).
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?;
let udp_port = probe.local_addr()?.port();
drop(probe);
// Reserve the data-plane UDP socket up front and HOLD it through streaming (no
// bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
// `--data-port` yields `direct = true` (stream straight to the client's reported address,
// no punch-wait); otherwise a random ephemeral port + hole-punch.
let (data_sock, direct) = bind_data_socket(data_port)?;
let udp_port = data_sock.local_addr()?.port();
let mut key = [0u8; 16];
rand::thread_rng().fill_bytes(&mut key);
@@ -860,9 +995,11 @@ async fn serve_session(
let start = Start::decode(&io::read_msg(&mut recv).await?)
.map_err(|e| anyhow!("Start decode: {e:?}"))?;
Ok::<_, anyhow::Error>((hello, welcome, udp_port, start, compositor))
Ok::<_, anyhow::Error>((
hello, welcome, udp_port, data_sock, direct, start, compositor,
))
};
let (hello, welcome, udp_port, start, compositor) =
let (hello, welcome, udp_port, data_sock, direct, start, compositor) =
tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake)
.await
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
@@ -1046,15 +1183,50 @@ async fn serve_session(
// Stop signal: stream duration elapsed or the client went away.
let stop = Arc::new(AtomicBool::new(false));
// Deliberate-quit signal: set (before `stop`, so the display lease reads it on teardown) when the
// client closed the connection with `QUIT_CODE` — a user "stop", which skips the keep-alive linger.
// A bare disconnect / idle timeout leaves it false → the display lingers for a reconnect.
let quit = Arc::new(AtomicBool::new(false));
{
let stop = stop.clone();
let quit = quit.clone();
let conn = conn.clone();
tokio::spawn(async move {
conn.closed().await;
let reason = conn.closed().await;
if matches!(&reason, quinn::ConnectionError::ApplicationClosed(ac)
if ac.error_code == quinn::VarInt::from_u32(QUIT_CODE))
{
quit.store(true, Ordering::SeqCst);
}
stop.store(true, Ordering::SeqCst);
});
}
// Register this now-live session for mode-conflict admission (Stage 4): carry its identity, the
// negotiated mode, and its stop flag so a LATER connecting client's admission can see it and
// (under `steal`) signal it. The guard removes the entry when this session ends.
let _live_guard = {
let id = endpoint::peer_fingerprint(&conn);
let label = id
.map(|fp| {
fp.iter()
.take(4)
.map(|b| format!("{b:02x}"))
.collect::<String>()
})
.unwrap_or_else(|| "client".to_string());
crate::vdisplay::admission::register(
id,
(
welcome.mode.width,
welcome.mode.height,
welcome.mode.refresh_hz,
),
stop.clone(),
label,
)
};
// Audio plane (virtual source only — synthetic runs are protocol tests): desktop Opus
// → host→client QUIC datagrams, on its own native thread. Best-effort on every failure
// (no PipeWire audio, spawn error): the session continues without audio — and a spawn
@@ -1153,6 +1325,7 @@ async fn serve_session(
crate::encode::ChromaFormat::Yuv420
};
let stop_stream = stop.clone();
let quit_stream = quit.clone();
let fec_target_dp = fec_target.clone(); // data-plane handle to the adaptive-FEC target
let conn_stream = conn.clone(); // for sending the source's real HDR metadata (0xCE) mid-stream
// Per-AU host-timing emission (0xCF): only when the client advertised the cap bit. All
@@ -1168,29 +1341,41 @@ async fn serve_session(
.unwrap_or_else(|| conn.remote_address().ip().to_string());
let result: Result<()> = async {
tokio::task::spawn_blocking(move || -> Result<()> {
// Wait briefly for the client to hole-punch our data port, then stream to its OBSERVED
// source — so video traverses a NAT / stateful inter-VLAN firewall (the client and host
// can be on different subnets; control + side planes ride the client-initiated QUIC, but
// the raw video UDP needs the client to open the path first). Falls back to the
// client-reported address for clients that don't punch (flat-LAN, unchanged).
let (transport, punched) = match UdpTransport::connect_via_punch(
&format!("0.0.0.0:{udp_port}"),
&client_udp.to_string(),
std::time::Duration::from_millis(2500),
) {
// Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
// for the client's punch, then stream to its OBSERVED source, so video traverses a
// NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated
// QUIC, but the raw video UDP needs the client to open the path first); falls back to
// the reported address for clients that don't punch (flat-LAN, unchanged). With a fixed
// `--data-port` (`direct`), skip the punch-wait and stream straight to the reported
// address — the operator declared a reachable, firewall-opened port, so there's no
// punch-timeout to pay. (Direct trusts the reported port: it can't cross a client-side
// NAT that remaps it.)
let bound = if direct {
UdpTransport::from_socket(data_sock, &client_udp.to_string()).map(|t| (t, false))
} else {
UdpTransport::from_socket_punch(
data_sock,
&client_udp.to_string(),
std::time::Duration::from_millis(2500),
)
};
let (transport, punched) = match bound {
Ok(v) => v,
Err(e) => {
// Surface the failure here directly: a data-plane bind error would otherwise be
// reported only after teardown (and a teardown stall could swallow it entirely).
tracing::error!(error = %e, %client_udp, udp_port, "data-plane socket bind/hole-punch failed");
tracing::error!(error = %e, %client_udp, udp_port, "data-plane socket setup failed");
return Err(anyhow::Error::new(e)).context("bind data plane");
}
};
tracing::info!(
%client_udp,
udp_port,
direct,
punched,
"data plane bound (punched=true → streaming to the client's observed source; \
false → no hole-punch seen, using the reported address)"
"data plane bound (direct=true → fixed --data-port, streaming to the reported \
address with no hole-punch; else punched=true → the client's observed source, \
false → no punch seen, the reported address)"
);
let mut session = Session::new(cfg, Box::new(transport))
.map_err(|e| anyhow!("host session: {e:?}"))?;
@@ -1212,6 +1397,7 @@ async fn serve_session(
mode,
seconds,
stop: stop_stream,
quit: quit_stream,
reconfig: reconfig_rx,
keyframe: keyframe_rx,
compositor,
@@ -2751,6 +2937,9 @@ struct SessionContext {
seconds: u32,
/// Session stop flag (set on disconnect / reconnect-preempt).
stop: Arc<AtomicBool>,
/// Deliberate-quit flag (set when the client closed with `QUIT_CODE`): the display lease reads it
/// on teardown to skip the keep-alive linger for a user "stop" (vs. an unwanted disconnect).
quit: Arc<AtomicBool>,
/// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode.
reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
/// Client decode-recovery keyframe requests.
@@ -2810,6 +2999,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
mode,
seconds,
stop,
quit,
reconfig,
keyframe,
compositor,
@@ -2860,7 +3050,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
let (mut capturer, mut enc, mut frame, mut interval) =
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan)?;
build_pipeline_with_retry(&mut vd, mode, bitrate_kbps, bit_depth, plan, &quit)?;
// Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
#[cfg(target_os = "windows")]
drop(_idd_setup_guard);
@@ -3028,6 +3218,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bitrate_kbps,
bit_depth,
plan,
&quit,
)?;
Ok((new_vd, pipe))
})();
@@ -3071,7 +3262,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// Build the new pipeline BEFORE dropping the old one: the host already acked
// the switch as accepted, so a rebuild failure must not kill an otherwise
// healthy session — keep streaming the current mode and log instead.
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan) {
match build_pipeline(&mut vd, new_mode, bitrate_kbps, bit_depth, plan, &quit) {
Ok(next_pipe) => {
(capturer, enc, frame, interval) = next_pipe;
cur_mode = new_mode;
@@ -3192,6 +3383,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bitrate_kbps,
bit_depth,
plan,
&quit,
) {
Ok(p) => break p,
Err(e2) => {
@@ -3418,6 +3610,7 @@ fn build_pipeline_with_retry(
bitrate_kbps: u32,
bit_depth: u8,
plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
) -> Result<Pipeline> {
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
@@ -3444,7 +3637,7 @@ fn build_pipeline_with_retry(
const MAX_ATTEMPTS: u32 = 8;
let mut backoff = std::time::Duration::from_millis(500);
for attempt in 1..=MAX_ATTEMPTS {
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan) {
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit) {
Ok(pipe) => {
if attempt > 1 {
tracing::info!(attempt, "pipeline up after retry");
@@ -3507,8 +3700,15 @@ fn build_pipeline(
bitrate_kbps: u32,
bit_depth: u8,
plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
) -> Result<Pipeline> {
let vout = vd.create(mode).context("create virtual output")?;
// Acquire through the registry (design/display-management.md): on Linux this pools the display
// for keep-alive (reuse a kept one, or create + keep the backend's keepalive so it outlives the
// session per policy); on Windows it delegates to `vd.create` (the manager already leases). The
// returned `VirtualOutput`'s keepalive is a registry lease — the capturer holds it as before. The
// `quit` flag rides into the lease so a deliberate-quit teardown skips the keep-alive linger.
let vout = crate::vdisplay::registry::acquire(vd, mode, quit.clone())
.context("create virtual output")?;
// The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a
// virtual output at 60 Hz if the custom-mode install was rejected). Pace the encoder + frame
// clock to that, not the requested rate, so we don't emit phantom duplicate frames over a
@@ -3581,6 +3781,43 @@ mod tests {
assert!(adapt_fec(u32::MAX) <= FEC_MAX);
}
#[test]
fn data_socket_defaults_to_random_hole_punch() {
// No fixed port (and the explicit-0 alias) → a random ephemeral port, and NOT direct: the
// caller hole-punches.
for req in [None, Some(0)] {
let (sock, direct) = bind_data_socket(req).expect("bind random data socket");
assert!(!direct, "req={req:?} must hole-punch, not stream direct");
assert_ne!(sock.local_addr().unwrap().port(), 0);
}
}
#[test]
fn data_socket_fixed_binds_direct_then_falls_back_when_busy() {
// Learn a currently-free port (bind :0, read it, drop — the same reserve-then-rebind the
// host itself uses; a race here would only make the assert below flaky, not wrong).
let free = std::net::UdpSocket::bind("0.0.0.0:0")
.unwrap()
.local_addr()
.unwrap()
.port();
// A free fixed port binds exactly it, in DIRECT mode (no hole-punch).
let (held, direct) = bind_data_socket(Some(free)).expect("bind fixed data socket");
assert!(direct, "a fixed --data-port must stream direct");
assert_eq!(held.local_addr().unwrap().port(), free);
// While it's held, a second session on the same fixed port can't bind it → it must fall
// back to a random port + hole-punch rather than fail (so concurrency never regresses).
let (fallback, direct2) = bind_data_socket(Some(free)).expect("busy fixed port falls back");
assert!(!direct2, "a busy fixed port must fall back to hole-punch");
assert_ne!(
fallback.local_addr().unwrap().port(),
free,
"the fallback must not reuse the busy fixed port"
);
}
#[test]
fn compositor_resolution_precedence() {
use crate::vdisplay::Compositor::*;
@@ -3756,10 +3993,18 @@ mod tests {
/// End-to-end through the C ABI — the exact contract platform clients (Swift) link:
/// in-process punktfunk/1 host, `punktfunk_connect` (TOFU → pinned reconnect) →
/// `punktfunk_connection_next_au` pulls verified frames → `punktfunk_connection_send_input`
/// In-process-host tests each spin up a host on a fixed loopback port and share the process-global
/// admission table, so they must NOT run concurrently: a same-identity connection in one test would
/// fire the reconnect-preempt (`preempt_same_identity`) against another test's live session and
/// close it. Serialize them on this lock. Poison-tolerant (`into_inner`) so a failing test doesn't
/// cascade a poison error into the others.
static SESSION_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// enqueues → `punktfunk_connection_close`. Three sequential sessions against ONE host
/// process prove the persistent listener, and a wrong pin is rejected.
#[test]
fn c_abi_connection_roundtrip() {
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
use punktfunk_core::abi::{
punktfunk_connect, punktfunk_connection_close, punktfunk_connection_mode,
punktfunk_connection_send_input,
@@ -3778,6 +4023,8 @@ mod tests {
allow_pairing: false,
pairing_pin: None,
paired_store: None,
data_port: None,
idle_timeout: None,
})
});
std::thread::sleep(std::time::Duration::from_millis(500));
@@ -3946,6 +4193,7 @@ mod tests {
/// admitted to a session with no PIN and no reconnect.
#[test]
fn delegated_approval_admits_after_knock() {
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
@@ -3972,6 +4220,8 @@ mod tests {
allow_pairing: false,
pairing_pin: None,
paired_store: None, // unused: the shared `np` IS the store handle
data_port: None,
idle_timeout: None,
},
0, // no mgmt API in this test → advertise no `mgmt` mDNS port
np_host,
@@ -4055,6 +4305,7 @@ mod tests {
/// identity gets a session on a pairing-required host; an anonymous client does not.
#[test]
fn pairing_ceremony_and_gate() {
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
use punktfunk_core::client::NativeClient;
use punktfunk_core::quic::endpoint;
@@ -4070,6 +4321,8 @@ mod tests {
allow_pairing: false,
pairing_pin: Some("4321".into()),
paired_store: Some(test_paired_path()),
data_port: None,
idle_timeout: None,
})
});
std::thread::sleep(std::time::Duration::from_millis(500));
+117 -17
View File
@@ -64,6 +64,43 @@ pub trait VirtualDisplay: Send {
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual
/// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {}
/// The stable identity slot the backend resolved for the most recent [`create`](Self::create) —
/// the per-client id the identity policy assigned (`Some`), or `None` for shared/anonymous. The
/// registry reads it right after `create` to key the display's group **arrangement** (manual
/// per-slot positions) and to label the mgmt `/display/state` slot. Default `None`: a backend
/// with no per-client identity (Mutter/wlroots/gamescope) always auto-rows. Only KWin (per-slot
/// output naming) reports a real slot on Linux.
fn last_identity_slot(&self) -> Option<u32> {
None
}
/// Place the most-recently-[created](Self::create) output at `(x, y)` in the desktop coordinate
/// space (design `display-management.md` §6.2 — layout). The registry, which owns the display
/// **group**, computes the position from the whole group (auto-row or the console's manual
/// arrangement) and calls this right after `create`. Default no-op: only backends that can position
/// an output (KWin) implement it; the registry never calls it for the desktop origin `(0, 0)`, so a
/// single-display / first-of-group session issues no positioning at all. Best-effort — a failure
/// leaves the compositor's default placement.
fn apply_position(&mut self, _x: i32, _y: i32) {}
/// Take the topology **restore** action this [`create`](Self::create) prepared — the work that
/// un-does an `exclusive`/`primary` topology change (e.g. re-enable the physical outputs KWin
/// disabled). The registry lifts it into the display **group** so it runs **once, when the group's
/// last display is torn down** (design §6.1 — per-group restore), not when this one session's
/// display drops: a sibling `exclusive` session must not have the physical re-enabled under it.
/// Called right after `create`; the backend must not also run it itself. Default `None` — a backend
/// whose topology auto-reverts (Mutter `APPLY_TEMPORARY`) or that changes nothing has nothing to
/// hand off.
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
None
}
/// Tell the backend whether this create will be the **first** display in its group — i.e. no
/// sibling of the same backend is already live (design §6.1). A backend that *establishes* the
/// group's topology (Mutter's sole-monitor `exclusive` `ApplyMonitorsConfig`) applies it only when
/// first; a later sibling **extends** into the already-exclusive desktop instead of re-clobbering it
/// (a fresh sole-monitor config would disable the first session's virtual output). Set by the
/// registry right before [`create`](Self::create). Default no-op: KWin recognises siblings at
/// runtime by output name (first-slot-wins + a group-aware disable filter), and single-display
/// backends never have a sibling.
fn set_first_in_group(&mut self, _first: bool) {}
}
/// Compositors punktfunk knows how to drive (plan §6).
@@ -403,21 +440,11 @@ pub fn apply_session_env(active: &ActiveSession) {
if active.kind == ActiveKind::DesktopGnome {
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
}
// Stream the desktop as the SOLE output: promote the per-session virtual output to PRIMARY so
// the panels + windows land on the streamed surface, not an unstreamed real output (the
// auto-detected desktop path *is* "stream this desktop"). Default-on for the auto path; an
// explicit `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` still wins.
match active.kind {
ActiveKind::DesktopKde if std::env::var_os("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY").is_none() => {
std::env::set_var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY", "1");
}
ActiveKind::DesktopGnome
if std::env::var_os("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY").is_none() =>
{
std::env::set_var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY", "1");
}
_ => {}
}
// Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
// [`effective_topology`] directly at create time — the console policy, else the legacy
// `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
// auto-desktop path). So this connect-path no longer writes that env (one fewer process-env
// mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result.
}
#[cfg(not(target_os = "linux"))]
pub fn apply_session_env(_active: &ActiveSession) {}
@@ -723,14 +750,87 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
std::sync::Arc::new(())
}
// The user-configurable management policy (keep-alive / topology / conflict / identity / layout),
// layered above the per-compositor backends — platform-neutral (the mgmt API + both host paths read
// it), so no cfg gate. See `design/display-management.md`.
#[path = "vdisplay/policy.rs"]
pub(crate) mod policy;
// The pure per-display lifecycle state machine (refcount + linger + pin), platform-neutral and
// property-tested; the registry executes the side effects its transitions dictate.
#[path = "vdisplay/lifecycle.rs"]
pub(crate) mod lifecycle;
// The neutral snapshot/release facade over the per-OS lifecycle owners (Windows manager; Linux pool
// later), for the management API's /display/state + /display/release.
#[path = "vdisplay/registry.rs"]
pub(crate) mod registry;
// The pure display-arrangement engine (auto-row / manual → per-member positions), platform-neutral
// and unit-tested; the registry (state readout) and the KWin position apply consume it.
#[path = "vdisplay/layout.rs"]
pub(crate) mod layout;
/// Resolve a [`policy::Topology`] to a concrete value (never [`policy::Topology::Auto`]). `Auto`
/// reproduces today's default: **extend** under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test
/// posture, where the host isn't the sole desktop), else **exclusive** (Windows + the auto-detected
/// Linux desktop path, where "stream this desktop" means promoting the virtual output to sole).
pub fn resolve_topology(t: policy::Topology) -> policy::Topology {
match t {
policy::Topology::Auto => {
if crate::config::config().compositor.is_some() {
policy::Topology::Extend
} else {
policy::Topology::Exclusive
}
}
concrete => concrete,
}
}
/// The concrete display topology for the current session — what the per-compositor backends (and the
/// Windows isolate gate) apply at create time. Precedence, mirroring the rest of the policy surface:
/// the **console policy** when configured, else the legacy **`PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY`**
/// env (an operator's explicit choice — `1`→exclusive, `0`→extend), else the **Auto** default
/// ([`resolve_topology`]: exclusive on the auto-detected desktop / Windows, extend under a compositor
/// pin). Always resolved (never [`policy::Topology::Auto`]). This is the Stage-2 replacement for the
/// `apply_session_env` boolean write — the backends read policy directly, so the `primary` level
/// (distinct from `exclusive`) becomes expressible and one process-env mutation drops off the connect
/// path.
pub fn effective_topology() -> policy::Topology {
if let Some(e) = policy::prefs().configured_effective() {
return resolve_topology(e.topology);
}
// Unconfigured: honor a legacy operator env if present (a host runs one desktop backend, so at
// most one of these is set), else the Auto default.
let legacy = [
"PUNKTFUNK_KWIN_VIRTUAL_PRIMARY",
"PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY",
]
.iter()
.find_map(|k| std::env::var(k).ok());
match legacy.as_deref().map(str::trim) {
Some("1" | "true" | "yes" | "on") => policy::Topology::Exclusive,
Some("0" | "false" | "no" | "off") => policy::Topology::Extend,
_ => resolve_topology(policy::Topology::Auto),
}
}
// Goal-1 stage 6: per-compositor Linux backends under `vdisplay/linux/`, the Windows IddCx/SudoVDA
// backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/gamescope.rs"]
mod gamescope;
#[cfg(target_os = "windows")]
#[path = "vdisplay/windows/identity.rs"]
// Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
// ConnectorIndex from the id; KWin names its output from it. `allow(dead_code)` because only Windows
// consumes it in non-test code today — the KWin wiring is the next Stage-3 step.
#[allow(dead_code)]
#[path = "vdisplay/identity.rs"]
pub(crate) mod identity;
// Platform-neutral mode-conflict admission (Stage 4): the separate/join/steal/reject decision + the
// live-session registry, wired into the punktfunk/1 handshake.
#[path = "vdisplay/admission.rs"]
pub(crate) mod admission;
#[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin.rs"]
mod kwin;
@@ -0,0 +1,278 @@
//! Mode-conflict **admission** (design: `design/display-management.md` §5.3, Stage 4). When a
//! *different* client connects while another client's session is already live, the `mode_conflict`
//! policy decides what happens — BEFORE the Welcome / RTSP launch, so the client gets an honest answer
//! instead of a mid-build failure:
//!
//! * `separate` — proceed on a fresh display at the requested mode (today's Linux multi-view / the
//! default; no behavior change unconfigured).
//! * `join` — admit at the live display's mode (honest-downgrade: the Welcome carries the real mode).
//! * `steal` — signal the victim session(s)' stop flag(s), wait the release grace, then serve.
//! * `reject` — refuse with a typed handshake error naming the live mode + client.
//!
//! A **live-session registry** ([`register`]) lets the decision see the current sessions (identity +
//! mode + stop flag); each session registers once admitted and drops its [`LiveGuard`] on end. The
//! decision itself ([`decide`]) is pure over a session slice, so it is unit-tested exhaustively.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use crate::vdisplay::policy::{self, ModeConflict};
/// A currently-live session, as admission sees it.
#[derive(Clone)]
pub struct LiveSession {
id: u64,
/// The owning client's cert fingerprint (`None` = anonymous / no client cert presented).
pub identity: Option<[u8; 32]>,
pub mode: (u32, u32, u32),
/// The session's stop flag — signaled to preempt it on `steal`.
pub stop: Arc<AtomicBool>,
/// Short client label for `reject` messages.
pub label: String,
}
/// The admission outcome for a connecting session.
#[derive(Debug)]
pub enum Admission {
/// No conflict / `separate`: proceed on a fresh display at the requested mode.
Separate,
/// `join`: admit at this (live) mode — share the existing display (honest-downgrade).
Join((u32, u32, u32)),
/// `steal`: signal these victim stop flags, wait the release grace, then proceed at the requested mode.
Steal(Vec<Arc<AtomicBool>>),
/// `reject`: refuse with this reason (host-busy + live mode + client label).
Reject(String),
}
fn table() -> &'static Mutex<Vec<LiveSession>> {
static T: OnceLock<Mutex<Vec<LiveSession>>> = OnceLock::new();
T.get_or_init(|| Mutex::new(Vec::new()))
}
static NEXT_ID: AtomicU64 = AtomicU64::new(1);
/// Two identities are the same client iff both are present and equal. Anonymous (`None`) never
/// matches — we can't prove it's the same client, so two anonymous clients are treated as distinct
/// (each conflicts), which is the safe side for `steal`/`reject`.
fn same_client(a: Option<[u8; 32]>, b: Option<[u8; 32]>) -> bool {
matches!((a, b), (Some(x), Some(y)) if x == y)
}
/// The mode-conflict decision, pure over the live-session slice (so it's unit-testable). A conflict is
/// a live session owned by a DIFFERENT client — a same-client reconnect adopts / reconfigures its own
/// display and never conflicts (so it always resolves to `Separate` here and preempts downstream).
pub fn decide(
conflict: ModeConflict,
req_identity: Option<[u8; 32]>,
live: &[LiveSession],
) -> Admission {
let others: Vec<&LiveSession> = live
.iter()
.filter(|s| !same_client(s.identity, req_identity))
.collect();
if others.is_empty() {
return Admission::Separate; // no other client is live → no conflict
}
match conflict {
ModeConflict::Separate => Admission::Separate,
// Join at the OLDEST other session's mode (the established "primary" the desktop is built on).
ModeConflict::Join => Admission::Join(others[0].mode),
ModeConflict::Steal => {
Admission::Steal(others.iter().map(|s| Arc::clone(&s.stop)).collect())
}
ModeConflict::Reject => {
let v = others[0];
Admission::Reject(format!(
"host busy: streaming {}x{}@{} to {}",
v.mode.0, v.mode.1, v.mode.2, v.label
))
}
}
}
/// The effective `mode_conflict` policy for THIS host: the console value (default `Separate` when
/// unconfigured), with the **Windows default applied**. On Windows `separate` — including the
/// unconfigured default — resolves to **`reject`**: two concurrent Windows sessions would both drive the
/// SAME pf-vdisplay monitor's single-capturer IDD-push channel ("newest-delivery-wins"), which freezes
/// the live client and can wedge the driver (true multi-session capture is §6.6 / Stage 7). So a 2nd
/// client gets a clean 503 and the live session is protected; `join`/`steal` stay as explicit opt-ins.
/// Linux keeps `separate` (real multi-view). Shared by the native + GameStream admission paths.
pub fn effective_conflict() -> ModeConflict {
let conflict = policy::prefs()
.configured_effective()
.map(|e| e.mode_conflict)
.unwrap_or(ModeConflict::Separate);
#[cfg(windows)]
if matches!(conflict, ModeConflict::Separate) {
return ModeConflict::Reject;
}
conflict
}
/// Resolve the admission decision for a connecting native session: [`effective_conflict`] + [`decide`]
/// against the live set.
pub fn admit(req_identity: Option<[u8; 32]>) -> Admission {
decide(effective_conflict(), req_identity, &table().lock().unwrap())
}
/// Pure core of [`preempt_same_identity`]: the stop flags of live sessions owned by the SAME client
/// as `req_identity` (its own zombies). Testable over a slice (the public fn locks the global table).
fn same_identity_stops(
req_identity: Option<[u8; 32]>,
live: &[LiveSession],
) -> Vec<Arc<AtomicBool>> {
live.iter()
.filter(|s| same_client(s.identity, req_identity))
.map(|s| Arc::clone(&s.stop))
.collect()
}
/// Preempt this reconnecting client's OWN still-live session(s). A client has at most one live
/// session, so a new connection from an already-registered identity is a **reconnect** — the old
/// session is a zombie whose QUIC idle timer hasn't fired yet (an unwanted disconnect is only
/// declared dead after `max_idle_timeout`, ~seconds later). Return its stop flag(s) so the caller
/// signals them and waits the release grace: the zombie tears its display down, which (keep-alive on)
/// lingers, and THIS reconnect **reuses** that kept display instead of landing on a fresh SECOND one
/// (the "thrown onto a second display while the old one keeps streaming" bug). Anonymous (`None`)
/// never matches — same limitation as `steal`/`reject`. Call this BEFORE [`admit`] and before this
/// session registers itself, so it only ever signals a *prior* session's flag, never its own.
pub fn preempt_same_identity(req_identity: Option<[u8; 32]>) -> Vec<Arc<AtomicBool>> {
same_identity_stops(req_identity, &table().lock().unwrap())
}
/// Register a now-admitted, live session; the returned guard removes it on drop (session end). Call
/// AFTER [`admit`] (so a session never conflicts with itself) and once the mode + stop flag are known.
pub fn register(
identity: Option<[u8; 32]>,
mode: (u32, u32, u32),
stop: Arc<AtomicBool>,
label: String,
) -> LiveGuard {
let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
table().lock().unwrap().push(LiveSession {
id,
identity,
mode,
stop,
label,
});
LiveGuard { id }
}
/// RAII handle: removes its live-session entry from the registry on drop (session end).
pub struct LiveGuard {
id: u64,
}
impl Drop for LiveGuard {
fn drop(&mut self) {
table().lock().unwrap().retain(|s| s.id != self.id);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sess(identity: Option<u8>, mode: (u32, u32, u32)) -> LiveSession {
LiveSession {
id: 0,
identity: identity.map(|n| {
let mut f = [0u8; 32];
f[0] = n;
f
}),
mode,
stop: Arc::new(AtomicBool::new(false)),
label: "peer".into(),
}
}
fn fp(n: u8) -> Option<[u8; 32]> {
let mut f = [0u8; 32];
f[0] = n;
Some(f)
}
#[test]
fn no_live_session_is_always_separate() {
for c in [
ModeConflict::Separate,
ModeConflict::Join,
ModeConflict::Steal,
ModeConflict::Reject,
] {
assert!(matches!(decide(c, fp(1), &[]), Admission::Separate));
}
}
#[test]
fn same_client_never_conflicts() {
let live = [sess(Some(1), (2560, 1440, 60))];
// Even under reject/steal, the SAME client (fp 1) reconnecting is not a conflict.
assert!(matches!(
decide(ModeConflict::Reject, fp(1), &live),
Admission::Separate
));
assert!(matches!(
decide(ModeConflict::Steal, fp(1), &live),
Admission::Separate
));
}
#[test]
fn different_client_applies_policy() {
let live = [sess(Some(1), (2560, 1440, 60))];
assert!(matches!(
decide(ModeConflict::Separate, fp(2), &live),
Admission::Separate
));
assert!(matches!(
decide(ModeConflict::Join, fp(2), &live),
Admission::Join((2560, 1440, 60))
));
assert!(matches!(
decide(ModeConflict::Steal, fp(2), &live),
Admission::Steal(v) if v.len() == 1
));
assert!(matches!(
decide(ModeConflict::Reject, fp(2), &live),
Admission::Reject(r) if r.contains("2560x1440@60")
));
}
#[test]
fn two_anonymous_clients_conflict() {
// Anonymous (None) can't be proven same-client, so a second anon client DOES conflict.
let live = [sess(None, (1920, 1080, 60))];
assert!(matches!(
decide(ModeConflict::Reject, None, &live),
Admission::Reject(_)
));
}
#[test]
fn same_identity_stops_targets_own_zombie_only() {
let live = [
sess(Some(1), (2560, 1440, 60)), // this client's prior (zombie) session
sess(Some(2), (1920, 1080, 60)), // a different client
];
// Reconnecting as client 1 → its own zombie's stop is returned (to preempt), not client 2's.
assert_eq!(same_identity_stops(fp(1), &live).len(), 1);
// A client with no prior session (fp 3) has nothing of its own to preempt.
assert_eq!(same_identity_stops(fp(3), &live).len(), 0);
// Anonymous never matches — we can't prove it's the same client.
assert_eq!(same_identity_stops(None, &live).len(), 0);
}
#[test]
fn join_targets_the_oldest_other_session() {
let live = [
sess(Some(1), (3840, 2160, 60)), // oldest
sess(Some(2), (1280, 720, 120)),
];
assert!(matches!(
decide(ModeConflict::Join, fp(3), &live),
Admission::Join((3840, 2160, 60))
));
}
}
@@ -0,0 +1,246 @@
//! Platform-neutral **per-client → stable display-id map** (design: `design/display-management.md`
//! §5.4 — identity). A client that reconnects gets the SAME small stable id every time, so the
//! desktop environment can key its per-display config (notably **DPI scaling**) to it and reapply it:
//!
//! * **Windows** seeds the pf-vdisplay monitor's EDID serial + IddCx `ConnectorIndex` from the id, so
//! Windows reapplies the client's saved `PerMonitorSettings` scaling. The id must stay `1..=15`
//! (`ConnectorIndex < MaxMonitorsSupported = 16`).
//! * **KWin** names the streamed output `Virtual-punktfunk-<id>`; KWin persists per-output scale/mode
//! in `kwinoutputconfig.json` matched by name, so a stable per-client name makes KDE reapply that
//! client's scaling. (Generalised here from the Windows-only map; the KWin wiring is Stage 3.)
//!
//! The map key is a composable string ([`identity_key`]): the client cert fingerprint alone
//! (`per-client`), or fingerprint + resolution (`per-client-mode` — distinct scaling per resolution).
//! Anonymous/TOFU/GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream,
//! never reaching this map.
//!
//! Persisted to `<config>/display-identity.json` (migrated from the legacy Windows
//! `pf-vdisplay-identity.json`) so ids — and the client→config association — survive host restarts.
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use serde::{Deserialize, Serialize};
/// Max stable id. Bounded by the Windows driver's use of the id as the IddCx `ConnectorIndex`
/// (`< MaxMonitorsSupported = 16`), so ids run `1..=15` on every platform for a single shared map.
const MAX_ID: u32 = 15;
/// The map filename (migrated from the legacy Windows-only `pf-vdisplay-identity.json`).
const FILE: &str = "display-identity.json";
const LEGACY_FILE: &str = "pf-vdisplay-identity.json";
/// Compose the map key for a client. `per_client_mode` appends the resolution so a client keeps a
/// distinct id (and thus distinct persisted scaling) per resolution; otherwise the fingerprint alone.
pub(crate) fn identity_key(fp: [u8; 32], mode: (u32, u32), per_client_mode: bool) -> String {
let hex: String = fp.iter().map(|b| format!("{b:02x}")).collect();
if per_client_mode {
format!("{hex}@{}x{}", mode.0, mode.1)
} else {
hex
}
}
#[derive(Serialize, Deserialize, Default)]
struct Store {
/// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so
/// the LRU ordering survives host restarts.
tick: u64,
entries: Vec<Entry>,
}
#[derive(Serialize, Deserialize)]
struct Entry {
/// The composed client key ([`identity_key`]) — the map key. (Serialized as `fp` for
/// back-compat with the legacy Windows `pf-vdisplay-identity.json`.)
#[serde(rename = "fp")]
key: String,
/// The client's stable display id (`1..=15`).
id: u32,
/// MRU stamp (compared against [`Store::tick`]).
seen: u64,
}
/// Persistent client-key → stable-id map (see the module docs).
pub(crate) struct DisplayIdentityMap {
path: PathBuf,
store: Store,
}
impl DisplayIdentityMap {
/// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just
/// re-derives ids, costing a client one scaling re-set the first time). Migrates the legacy
/// Windows `pf-vdisplay-identity.json` if the new file is absent.
pub(crate) fn load() -> Self {
let dir = crate::gamestream::config_dir();
let path = dir.join(FILE);
let bytes = std::fs::read(&path)
.or_else(|_| std::fs::read(dir.join(LEGACY_FILE)))
.ok();
let mut store = bytes
.and_then(|b| serde_json::from_slice::<Store>(&b).ok())
.unwrap_or_default();
// SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s
// found-entry branch returns the stored id verbatim, so an out-of-range id (0 = the "auto"
// sentinel, or > MAX_ID) or a duplicate id/key would flow straight into the display identity.
// Drop out-of-range ids and dedup by BOTH key and id (keeping the most-recently-seen on a
// clash) so no two clients can map to the same id.
store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen));
let mut seen_key = std::collections::HashSet::new();
let mut seen_id = std::collections::HashSet::new();
store.entries.retain(|e| {
(1..=MAX_ID).contains(&e.id) && seen_key.insert(e.key.clone()) && seen_id.insert(e.id)
});
Self { path, store }
}
/// The stable id (`1..=15`) for the client `key` ([`identity_key`]): its remembered id, or a
/// freshly assigned one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists.
pub(crate) fn resolve(&mut self, key: &str) -> u32 {
self.store.tick = self.store.tick.wrapping_add(1);
let now = self.store.tick;
if let Some(e) = self.store.entries.iter_mut().find(|e| e.key == key) {
e.seen = now;
let id = e.id;
self.persist();
return id;
}
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and
// reuse its id (the evicted client re-establishes its scaling once on its next connect).
let id = (1..=MAX_ID)
.find(|i| !self.store.entries.iter().any(|e| e.id == *i))
.unwrap_or_else(|| {
let lru = self
.store
.entries
.iter()
.enumerate()
.min_by_key(|(_, e)| e.seen)
.map(|(i, _)| i)
.expect("entries are non-empty whenever every id 1..=MAX_ID is taken");
let evicted = self.store.entries.remove(lru);
evicted.id
});
self.store.entries.push(Entry {
key: key.to_string(),
id,
seen: now,
});
self.persist();
id
}
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may
/// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine.
fn persist(&self) {
let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else {
return;
};
if let Some(dir) = self.path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, &self.path);
}
}
}
/// The process-wide identity map (persisted, loaded once). Shared by the Windows manager and the
/// Linux KWin backend — never in the same process (a host runs one platform), so one instance ⇒ no
/// clobbering of the shared `display-identity.json`.
pub(crate) fn global() -> &'static Mutex<DisplayIdentityMap> {
static MAP: OnceLock<Mutex<DisplayIdentityMap>> = OnceLock::new();
MAP.get_or_init(|| Mutex::new(DisplayIdentityMap::load()))
}
/// Resolve the connecting client's stable slot id per the `identity` policy. When no policy is
/// configured, `default` applies — **PerClient on Windows / Shared on Linux**, preserving each
/// platform's historical behavior (Windows always keyed monitors per-client; Linux used one shared
/// output name). `None` ⇒ shared / anonymous → the backend uses its base name / auto slot.
pub(crate) fn resolve_slot(
fp: Option<[u8; 32]>,
mode: (u32, u32),
default: crate::vdisplay::policy::Identity,
) -> Option<u32> {
use crate::vdisplay::policy::Identity;
let id_policy = crate::vdisplay::policy::prefs()
.configured_effective()
.map(|e| e.identity)
.unwrap_or(default);
let per_client_mode = match id_policy {
Identity::Shared => return None,
Identity::PerClient => false,
Identity::PerClientMode => true,
};
let fp = fp?;
Some(
global()
.lock()
.unwrap()
.resolve(&identity_key(fp, mode, per_client_mode)),
)
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(n: u8) -> [u8; 32] {
let mut f = [0u8; 32];
f[0] = n;
f
}
fn temp_map(tag: &str) -> DisplayIdentityMap {
DisplayIdentityMap {
path: std::env::temp_dir().join(format!("pf-id-{tag}-{}.json", std::process::id())),
store: Store::default(),
}
}
#[test]
fn stable_across_calls_and_distinct_per_client() {
let mut m = temp_map("stable");
let a1 = m.resolve(&identity_key(fp(1), (1920, 1080), false));
let b = m.resolve(&identity_key(fp(2), (1920, 1080), false));
let a2 = m.resolve(&identity_key(fp(1), (1280, 720), false)); // per-client: mode ignored
assert_eq!(a1, a2, "same client → same id (per-client ignores mode)");
assert_ne!(a1, b, "distinct clients → distinct ids");
assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b));
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn per_client_mode_splits_by_resolution() {
let mut m = temp_map("permode");
let hd = m.resolve(&identity_key(fp(1), (1920, 1080), true));
let uhd = m.resolve(&identity_key(fp(1), (3840, 2160), true));
let hd2 = m.resolve(&identity_key(fp(1), (1920, 1080), true));
assert_ne!(hd, uhd, "same client, different resolution → different id");
assert_eq!(hd, hd2, "same client + resolution → same id");
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn lru_eviction_reuses_an_id_at_the_cap() {
let mut m = temp_map("lru");
for n in 1..=15u8 {
m.resolve(&identity_key(fp(n), (1920, 1080), false));
}
let _ = m.resolve(&identity_key(fp(2), (1920, 1080), false)); // touch 2 so 1 is LRU
let id16 = m.resolve(&identity_key(fp(16), (1920, 1080), false));
assert!((1..=MAX_ID).contains(&id16));
assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries");
assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id)));
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn key_composition() {
assert_eq!(identity_key(fp(0xab), (1920, 1080), false).len(), 64); // hex fp only
assert!(identity_key(fp(0xab), (1920, 1080), true).ends_with("@1920x1080"));
}
}
@@ -0,0 +1,142 @@
//! Pure display-**arrangement** engine (design: `design/display-management.md` §6.2). Given a
//! group's members (in acquire order) and the `layout` policy, compute each member's top-left
//! origin in the desktop coordinate space. No I/O, no OS types — the registry (for the
//! `/display/state` readout) and the per-backend position apply both consume it, so the auto-row /
//! manual math is defined and tested in exactly one place (the `pick_gamescope_mode` / `wiring_plan`
//! discipline).
//!
//! * **auto-row** — left-to-right in acquire order, top-aligned: member *i* sits at
//! `x = Σ widths[0..i]`, `y = 0`. This is what compositors mostly do by default, made
//! deterministic.
//! * **manual** — per-identity-slot offsets from [`Layout::positions`] (console-arranged): a member
//! whose stable identity slot has a stored position sits there; a member with no pin (no stored
//! position, or a shared/anonymous identity that has no slot) falls back to its auto-row origin, so
//! a half-arranged group never collapses everything onto the origin.
//!
//! Group membership + acquire order live in the registry ([`super::registry`]); this file only turns
//! that ordered member list into positions.
use super::policy::{Layout, LayoutMode};
/// One display in a group, as the arranger sees it (given in acquire order).
#[derive(Clone, Copy, Debug)]
pub struct Member {
/// Stable per-client identity slot — the manual-layout key. `None` for a shared/anonymous
/// identity (no per-client slot), which can't carry a manual pin and therefore always auto-rows.
pub identity_slot: Option<u32>,
/// Pixel width, for auto-row `x` accumulation. Clamped at 0 (a bogus negative never shifts a
/// sibling left).
pub width: i32,
}
/// A member's resolved desktop-space top-left origin.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Placement {
pub x: i32,
pub y: i32,
}
/// The auto-row origin of member `i`: the summed width of every prior member, top-aligned.
fn auto_row_x(members: &[Member], i: usize) -> i32 {
members[..i].iter().map(|m| m.width.max(0)).sum()
}
/// Arrange `members` (in acquire order) per `layout`, returning one [`Placement`] per member in the
/// same order. Pure — the single source of truth for auto-row / manual placement, shared by the
/// state readout and (KWin) the per-backend position apply.
pub fn arrange(members: &[Member], layout: &Layout) -> Vec<Placement> {
members
.iter()
.enumerate()
.map(|(i, m)| {
let auto = Placement {
x: auto_row_x(members, i),
y: 0,
};
match layout.mode {
LayoutMode::AutoRow => auto,
// A pinned member sits at its stored offset; an unpinned one falls back to auto-row.
LayoutMode::Manual => m
.identity_slot
.and_then(|slot| layout.positions.get(&slot.to_string()))
.map(|p| Placement { x: p.x, y: p.y })
.unwrap_or(auto),
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vdisplay::policy::Position;
use std::collections::BTreeMap;
fn m(slot: Option<u32>, width: i32) -> Member {
Member {
identity_slot: slot,
width,
}
}
fn manual(pairs: &[(&str, i32, i32)]) -> Layout {
let mut positions = BTreeMap::new();
for (k, x, y) in pairs {
positions.insert(k.to_string(), Position { x: *x, y: *y });
}
Layout {
mode: LayoutMode::Manual,
positions,
}
}
#[test]
fn auto_row_accumulates_widths_top_aligned() {
let members = [m(Some(1), 2560), m(Some(2), 1920), m(None, 1280)];
let out = arrange(&members, &Layout::default()); // default = AutoRow
assert_eq!(
out,
vec![
Placement { x: 0, y: 0 },
Placement { x: 2560, y: 0 },
Placement { x: 4480, y: 0 },
]
);
}
#[test]
fn manual_honors_pins_by_identity_slot() {
let members = [m(Some(1), 2560), m(Some(7), 1920)];
// Client 7 arranged to the LEFT of client 1 (crossing order reversed vs auto-row).
let layout = manual(&[("1", 1920, 0), ("7", 0, 0)]);
let out = arrange(&members, &layout);
assert_eq!(out[0], Placement { x: 1920, y: 0 });
assert_eq!(out[1], Placement { x: 0, y: 0 });
}
#[test]
fn manual_unpinned_and_slotless_fall_back_to_auto_row() {
let members = [m(Some(1), 2560), m(Some(9), 1920), m(None, 1280)];
// Only slot 1 is pinned; slot 9 has no stored pin; the third has no slot at all.
let layout = manual(&[("1", 100, 50)]);
let out = arrange(&members, &layout);
assert_eq!(out[0], Placement { x: 100, y: 50 }, "pinned");
assert_eq!(out[1], Placement { x: 2560, y: 0 }, "unpinned → auto-row");
assert_eq!(out[2], Placement { x: 4480, y: 0 }, "slotless → auto-row");
}
#[test]
fn empty_group_is_empty() {
assert!(arrange(&[], &Layout::default()).is_empty());
assert!(arrange(&[], &manual(&[("1", 0, 0)])).is_empty());
}
#[test]
fn negative_width_never_shifts_siblings_left() {
let members = [m(Some(1), -100), m(Some(2), 1920)];
let out = arrange(&members, &Layout::default());
let origin = Placement { x: 0, y: 0 };
assert_eq!(out[0], origin);
assert_eq!(out[1], origin, "clamped width contributes 0");
}
}
@@ -0,0 +1,338 @@
//! Pure per-display **lifecycle state machine** (design: `design/display-management.md` §3).
//!
//! One virtual display's earned refcount + linger + pin state, with **no I/O and no OS-specific
//! types** — the registry ([`super::registry`]) executes the side effects (backend create /
//! teardown / linger timer) that this machine's transitions dictate. Extracted so the lifecycle
//! logic is unit- and property-testable in isolation, and so the Linux registry and (later) the
//! Windows manager share one audited machine instead of each re-deriving refcount+linger by hand.
//!
//! It is the platform-neutral distillation of the model the Windows `VirtualDisplayManager` already
//! runs on glass: `Idle → Active{refs} → Lingering{until} → Idle`, plus a `Pinned` state for
//! keep-alive-forever. The registry pairs one [`State`] with the owned backend resource; the machine
//! only tracks the discriminant + refcount + deadline and reports what to do.
use std::time::Instant;
use super::policy::Linger;
/// The lifecycle state of one virtual-display slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
pub enum State {
/// No display exists.
#[default]
Idle,
/// A display exists with `refs` live sessions holding it.
Active { refs: u32 },
/// The last session left; the display is kept until `until`, then torn down.
Lingering { until: Instant },
/// The last session left; the display is kept indefinitely (keep-alive forever), until an
/// explicit release.
Pinned,
}
/// What acquiring a slot means for the backend.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Acquire {
/// The slot was empty — the backend must CREATE a fresh display.
Create,
/// The slot was already Active — another session JOINS the live display (refcount++).
Join,
/// The slot was kept alive (Lingering/Pinned) — REUSE the existing display (re-attach capture).
Reuse,
}
/// What releasing a hold on a slot means for the backend.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Release {
/// Another session still holds the display — nothing to do.
Decref,
/// The last session left; keep the display until its deadline ([`State::Lingering`]), then tear down.
Linger,
/// The last session left; keep the display indefinitely ([`State::Pinned`]).
Pin,
/// The last session left and keep-alive is off — tear the display down now.
Teardown,
/// A release with no live hold (stale/duplicate) — no-op.
Noop,
}
impl State {
/// True while a backend display resource exists (Active/Lingering/Pinned) — the registry holds
/// the keepalive in exactly these states, and `Idle` means it has been dropped.
pub fn has_display(self) -> bool {
!matches!(self, State::Idle)
}
/// Number of live sessions holding the display (0 unless Active).
pub fn refs(self) -> u32 {
match self {
State::Active { refs } => refs,
_ => 0,
}
}
/// A session acquires the slot. Transitions the state and reports whether the backend must
/// create a fresh display, join the live one, or reuse the kept one.
pub fn acquire(&mut self) -> Acquire {
match *self {
State::Idle => {
*self = State::Active { refs: 1 };
Acquire::Create
}
State::Active { refs } => {
*self = State::Active { refs: refs + 1 };
Acquire::Join
}
State::Lingering { .. } | State::Pinned => {
*self = State::Active { refs: 1 };
Acquire::Reuse
}
}
}
/// A session releases the slot. When the LAST session leaves, `now` + the resolved `linger`
/// decide the kept state. Returns what the registry should do.
pub fn release(&mut self, now: Instant, linger: Linger) -> Release {
match *self {
State::Active { refs } if refs > 1 => {
*self = State::Active { refs: refs - 1 };
Release::Decref
}
State::Active { .. } => match linger {
Linger::Immediate => {
*self = State::Idle;
Release::Teardown
}
Linger::For(d) => {
*self = State::Lingering { until: now + d };
Release::Linger
}
Linger::Forever => {
*self = State::Pinned;
Release::Pin
}
},
// Releasing a slot with no live hold is a stale/duplicate release. The registry's
// gen-stamped leases already make a stale lease's drop a no-op before it reaches here;
// this is the defensive backstop.
State::Idle | State::Lingering { .. } | State::Pinned => Release::Noop,
}
}
/// The registry's linger-timer tick: a Lingering slot past its deadline goes Idle and returns
/// `true` (the registry tears the display down). Pinned and every other state are untouched.
pub fn poll_expiry(&mut self, now: Instant) -> bool {
match *self {
State::Lingering { until } if now >= until => {
*self = State::Idle;
true
}
_ => false,
}
}
/// Force-release a kept display (the `/display/release` endpoint): a Lingering/Pinned slot goes
/// Idle and the registry tears it down (`true`). An Active slot is refused (`false`) — releasing
/// a display that still has live sessions is session management, not display management. Idle → `false`.
pub fn force_release(&mut self) -> bool {
match *self {
State::Lingering { .. } | State::Pinned => {
*self = State::Idle;
true
}
State::Active { .. } | State::Idle => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;
#[test]
fn create_join_reuse_and_teardown() {
let mut s = State::default();
assert_eq!(s.acquire(), Acquire::Create);
assert_eq!(s, State::Active { refs: 1 });
// A concurrent session joins.
assert_eq!(s.acquire(), Acquire::Join);
assert_eq!(s.refs(), 2);
// One leaves — still active.
let now = Instant::now();
assert_eq!(s.release(now, Linger::Immediate), Release::Decref);
assert_eq!(s.refs(), 1);
// The last leaves with keep-alive off — teardown.
assert_eq!(s.release(now, Linger::Immediate), Release::Teardown);
assert_eq!(s, State::Idle);
assert!(!s.has_display());
}
#[test]
fn linger_then_reuse_within_window() {
let mut s = State::default();
let t0 = Instant::now();
s.acquire();
assert_eq!(
s.release(t0, Linger::For(Duration::from_secs(10))),
Release::Linger
);
assert!(s.has_display());
// A tick before the deadline does nothing.
assert!(!s.poll_expiry(t0 + Duration::from_secs(5)));
// A reconnect inside the window reuses the kept display.
assert_eq!(s.acquire(), Acquire::Reuse);
assert_eq!(s, State::Active { refs: 1 });
}
#[test]
fn linger_expires_to_teardown() {
let mut s = State::default();
let t0 = Instant::now();
s.acquire();
s.release(t0, Linger::For(Duration::from_secs(10)));
// Past the deadline → teardown.
assert!(s.poll_expiry(t0 + Duration::from_secs(11)));
assert_eq!(s, State::Idle);
// A second tick is idempotent (nothing to tear down).
assert!(!s.poll_expiry(t0 + Duration::from_secs(12)));
}
#[test]
fn pinned_never_expires_but_force_releases() {
let mut s = State::default();
let t0 = Instant::now();
s.acquire();
assert_eq!(s.release(t0, Linger::Forever), Release::Pin);
assert_eq!(s, State::Pinned);
// No amount of ticking tears a pinned display down.
assert!(!s.poll_expiry(t0 + Duration::from_secs(86_400)));
assert!(s.has_display());
// Only an explicit release does.
assert!(s.force_release());
assert_eq!(s, State::Idle);
}
#[test]
fn force_release_refuses_active() {
let mut s = State::default();
s.acquire();
assert!(
!s.force_release(),
"an active display can't be force-released"
);
assert_eq!(s.refs(), 1);
// Idle also can't.
let mut idle = State::default();
assert!(!idle.force_release());
}
#[test]
fn stale_release_is_noop() {
let mut s = State::default();
assert_eq!(s.release(Instant::now(), Linger::Immediate), Release::Noop);
assert_eq!(s, State::Idle);
}
/// Property test (deterministic seeded walk): across an arbitrary interleaving of acquire /
/// release / expiry-tick / force-release, the machine must never (a) leak or double-free the
/// backend resource — `has_display()` must exactly track a shadow "resource alive" flag, with
/// every Create preceded by no live resource and every teardown preceded by one — nor (b)
/// underflow the refcount, nor (c) tear a display down while a session still holds it.
#[test]
fn property_no_leaks_no_double_free_no_underflow() {
// Tiny deterministic LCG (Numerical Recipes) — reproducible, no dependency.
let mut rng: u64 = 0x1234_5678_9abc_def0;
let mut next = || {
rng = rng
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(rng >> 33) as u32
};
let base = Instant::now();
let mut logical_ms: u64 = 0;
let mut s = State::default();
// Shadow model.
let mut resource_alive = false;
let mut live_holds: u32 = 0;
for _ in 0..200_000 {
// Advance logical time by 0..2000 ms each step so lingers cross their deadlines.
logical_ms += (next() % 2000) as u64;
let now = base + Duration::from_millis(logical_ms);
match next() % 5 {
0 => {
// acquire
let before_alive = resource_alive;
let a = s.acquire();
match a {
Acquire::Create => {
assert!(!before_alive, "Create while a resource was alive")
}
Acquire::Join | Acquire::Reuse => {
assert!(before_alive, "Join/Reuse with no live resource")
}
}
resource_alive = true;
live_holds += 1;
}
1 | 2 => {
// release (weighted 2/5 so refs actually drain)
let linger = match next() % 3 {
0 => Linger::Immediate,
1 => Linger::For(Duration::from_millis((next() % 3000) as u64 + 1)),
_ => Linger::Forever,
};
let held_before = live_holds;
let r = s.release(now, linger);
match r {
Release::Noop => assert_eq!(held_before, 0, "Noop only with no live hold"),
Release::Decref => {
assert!(held_before >= 2, "Decref must leave the display held");
live_holds -= 1;
}
Release::Teardown => {
assert_eq!(held_before, 1, "Teardown only on the last hold");
live_holds = 0;
resource_alive = false;
}
Release::Linger | Release::Pin => {
assert_eq!(held_before, 1, "Linger/Pin only on the last hold");
live_holds = 0;
// resource stays alive (kept)
}
}
}
3 => {
// expiry tick
if s.poll_expiry(now) {
assert_eq!(live_holds, 0, "expiry tore down a held display");
resource_alive = false;
}
}
_ => {
// force release
if s.force_release() {
assert_eq!(live_holds, 0, "force-release tore down a held display");
resource_alive = false;
}
}
}
// Invariant after every step: the machine's own view of "a display exists" matches the
// shadow, and the refcount matches the live-hold count.
assert_eq!(
s.has_display(),
resource_alive,
"has_display drifted from the shadow model"
);
assert_eq!(
s.refs(),
live_holds,
"refs drifted from the live-hold count"
);
}
}
}
+237 -71
View File
@@ -67,13 +67,42 @@ const VOUT_NAME: &str = "punktfunk";
/// event (deprecated only since v6) for the node id, so cap the bind at 5.
const MAX_VERSION: u32 = 5;
/// The KWin virtual-display driver. Stateless — each [`create`](VirtualDisplay::create) spins up
/// its own Wayland connection/thread that owns the resulting output.
pub struct KwinDisplay;
/// The KWin virtual-display driver. Carries the connecting client's cert fingerprint (set before
/// [`create`](VirtualDisplay::create)) so a paired client gets a STABLE per-slot output NAME
/// (`Virtual-punktfunk-<id>`) — KWin persists per-output config (scale/mode) keyed by name in
/// `kwinoutputconfig.json`, so a stable name makes KDE reapply that client's scaling on reconnect
/// (Stage 3). Each `create` spins up its own Wayland connection/thread that owns the output.
#[derive(Default)]
pub struct KwinDisplay {
client_fp: Option<[u8; 32]>,
/// The identity slot the last [`create`](VirtualDisplay::create) resolved (the per-client id, or
/// `None` for shared/anonymous) — reported to the registry via [`last_identity_slot`] so it can key
/// the group arrangement + `/display/state` slot to the same id this backend named the output with.
last_slot: Option<u32>,
/// The base output name the last `create` used (`punktfunk` / `punktfunk-<id>`) — so
/// [`apply_position`](VirtualDisplay::apply_position) can address the KWin output `Virtual-<name>`.
last_name: Option<String>,
/// 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
/// physical is re-enabled only when the display GROUP's last member drops (§6.1), not this session's.
/// A backstop [`Drop`] runs it if the registry never took it (so a physical is never left dark).
pending_restore: Option<Box<dyn FnOnce() + Send>>,
}
impl Drop for KwinDisplay {
fn drop(&mut self) {
// Backstop only: the registry takes the restore right after `create` (moving it into the group),
// so this is normally `None`. If some path skipped the take, re-enable here so a physical is
// never stranded dark.
if let Some(restore) = self.pending_restore.take() {
restore();
}
}
}
impl KwinDisplay {
pub fn new() -> Result<Self> {
Ok(KwinDisplay)
Ok(KwinDisplay::default())
}
}
@@ -82,14 +111,61 @@ impl VirtualDisplay for KwinDisplay {
"kwin"
}
fn set_client_identity(&mut self, fingerprint: Option<[u8; 32]>) {
self.client_fp = fingerprint;
}
fn last_identity_slot(&self) -> Option<u32> {
self.last_slot
}
fn take_topology_restore(&mut self) -> Option<Box<dyn FnOnce() + Send>> {
self.pending_restore.take()
}
fn apply_position(&mut self, x: i32, y: i32) {
let Some(name) = self.last_name.clone() else {
return;
};
let output = format!("Virtual-{name}");
// kscreen-doctor position syntax: `output.<name>.position.<x>,<y>`.
let ok = std::process::Command::new("kscreen-doctor")
.arg(format!("output.{output}.position.{x},{y}"))
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
tracing::info!(output, x, y, "KWin: placed output in the desktop layout");
} else {
tracing::warn!(output, x, y, "KWin: output position apply failed");
}
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
// Per-slot output name (Stage 3): the `identity` policy resolves the client to a stable id →
// `punktfunk-<id>` (KWin exposes `Virtual-punktfunk-<id>`, whose per-output config KWin
// persists by name). Shared / anonymous → the base `punktfunk` (today's single name). Linux
// defaults to Shared when unconfigured, so this is a no-op change until a policy opts in — AND
// it fixes the latent clash where two concurrent sessions both used `Virtual-punktfunk`.
let slot = crate::vdisplay::identity::resolve_slot(
self.client_fp,
(mode.width, mode.height),
crate::vdisplay::policy::Identity::Shared,
);
self.last_slot = slot; // reported to the registry for the group arrangement + state slot
let name = match slot {
Some(id) => format!("{VOUT_NAME}-{id}"),
None => VOUT_NAME.to_string(),
};
self.last_name = Some(name.clone()); // for apply_position (registry-driven §6.2 layout)
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let (width, height) = (mode.width, mode.height);
let name_thread = name.clone();
thread::Builder::new()
.name("punktfunk-kwin-vout".into())
.spawn(move || virtual_output_thread(width, height, setup_tx, stop_thread))
.spawn(move || virtual_output_thread(width, height, name_thread, setup_tx, stop_thread))
.context("spawn KWin virtual-output thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
@@ -107,36 +183,70 @@ impl VirtualDisplay for KwinDisplay {
// rejected custom mode leaves the output at 60 Hz). At ≤60 Hz there's nothing to install —
// the source runs 60 Hz and the encoder downsamples — so carry the requested rate through.
let achieved_hz = if mode.refresh_hz > 60 {
set_custom_refresh(width, height, mode.refresh_hz)
set_custom_refresh(width, height, mode.refresh_hz, &name)
} else {
mode.refresh_hz
};
// Make our streamed output the SOLE desktop: plasmashell + windows land on the surface we
// stream, not on the headless session's `kwin --virtual` bootstrap output (otherwise the
// client sees only the wallpaper of an empty extended output). Opt-in
// (PUNKTFUNK_KWIN_VIRTUAL_PRIMARY), mirroring the Mutter backend's PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY.
let restore = if virtual_primary_enabled() {
apply_virtual_primary()
} else {
Vec::new()
// Display-management topology (Stage 2): `Extend` leaves the streamed output an extension;
// `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
// 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).
use crate::vdisplay::policy::Topology;
let disabled = match crate::vdisplay::effective_topology() {
Topology::Exclusive => apply_virtual_primary(&name),
Topology::Primary => {
apply_virtual_primary_only(&name);
Vec::new() // nothing disabled → nothing to restore
}
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
// `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
// into the display group and runs once, when the group's LAST member is torn down (ordered before
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
self.pending_restore = (!disabled.is_empty()).then(|| {
let disabled = disabled.clone();
Box::new(move || reenable_outputs(&disabled)) as Box<dyn FnOnce() + Send>
});
// 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).
Ok(VirtualOutput {
node_id,
remote_fd: None,
preferred_mode: Some((mode.width, mode.height, achieved_hz)),
keepalive: Box::new(StopGuard { stop, restore }),
keepalive: Box::new(StopGuard { stop }),
})
}
}
/// Re-enable the outputs an `exclusive` topology disabled (bootstrap / physical), so KWin re-homes onto
/// them. Called by the registry 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(outputs: &[String]) {
if outputs.is_empty() {
return;
}
let args: Vec<String> = outputs
.iter()
.map(|o| format!("output.{o}.enable"))
.collect();
let _ = std::process::Command::new("kscreen-doctor")
.args(&args)
.status();
std::thread::sleep(Duration::from_millis(200));
tracing::info!(reenabled = ?outputs, "KWin: restored the physical/bootstrap outputs (group empty)");
}
/// Best-effort: raise the just-created virtual output's refresh above KWin's default 60 Hz by
/// installing + selecting a custom mode via `kscreen-doctor` (the output is `Virtual-<VOUT_NAME>`,
/// refresh given in mHz), then **read back the active mode** and return the refresh KWin actually
/// gave us. The apply command can report success yet leave the output at 60 Hz (mode rejected),
/// and a silent rate mismatch surfaces downstream as judder / duplicated frames — so the caller
/// paces the encoder to the *achieved* rate, not the requested one.
fn set_custom_refresh(width: u32, height: u32, hz: u32) -> u32 {
let output = format!("Virtual-{VOUT_NAME}");
fn set_custom_refresh(width: u32, height: u32, hz: u32, name: &str) -> u32 {
let output = format!("Virtual-{name}");
let mhz = hz.saturating_mul(1000);
let run = |arg: String| {
std::process::Command::new("kscreen-doctor")
@@ -213,26 +323,17 @@ fn read_active_refresh(output: &str) -> Option<u32> {
Some(hz.round() as u32)
}
/// Opt-in: make the per-session virtual output the sole desktop. Off by default — a host with no
/// competing output (or one that wants the bootstrap kept) is unaffected; the headless KDE appliance
/// (run-headless-kde.sh's `kwin --virtual` bootstrap + our streamed output) sets it so the desktop
/// renders on the streamed surface, not the bootstrap. Mirrors the Mutter backend's gate.
fn virtual_primary_enabled() -> bool {
std::env::var("PUNKTFUNK_KWIN_VIRTUAL_PRIMARY")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
/// The prefix EVERY managed KWin output shares — Stage 3 names them `punktfunk` / `punktfunk-<id>`,
/// which KWin exposes as `Virtual-punktfunk` / `Virtual-punktfunk-<id>`. Group membership (§6.1) is
/// recognised by this prefix, so we never have to thread the live set through the backend.
const MANAGED_PREFIX: &str = "Virtual-punktfunk";
/// Names of currently-ENABLED outputs other than our `Virtual-punktfunk` — i.e. the headless
/// session's bootstrap output(s), which hold the desktop by default. Parsed from `kscreen-doctor -j`
/// (same source as [`read_active_refresh`]).
/// Names of currently-ENABLED outputs that are **not managed by us** — the headless session's
/// bootstrap output(s) + any physical monitor, i.e. exactly what `exclusive` must disable.
/// **Group-aware (§6.1):** excludes the WHOLE managed family (the [`MANAGED_PREFIX`]), not just this
/// session's own output — so a 2nd `exclusive` session (with a distinct per-slot name) never disables
/// the 1st session's live output. Parsed from `kscreen-doctor -j` (same source as [`read_active_refresh`]).
fn other_enabled_outputs() -> Vec<String> {
let ours = format!("Virtual-{VOUT_NAME}");
let out = match std::process::Command::new("kscreen-doctor")
.arg("-j")
.output()
@@ -248,22 +349,52 @@ fn other_enabled_outputs() -> Vec<String> {
.and_then(|o| o.as_array())
.map(|outs| {
outs.iter()
.filter(|o| {
o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false)
&& o.get("name").and_then(|n| n.as_str()) != Some(ours.as_str())
})
.filter_map(|o| o.get("name").and_then(|n| n.as_str()).map(String::from))
.filter(|o| o.get("enabled").and_then(|e| e.as_bool()).unwrap_or(false))
.filter_map(|o| o.get("name").and_then(|n| n.as_str()))
.filter(|n| !n.starts_with(MANAGED_PREFIX))
.map(String::from)
.collect()
})
.unwrap_or_default()
}
/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so it becomes the sole
/// desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for the
/// keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
/// True if any managed group member (the [`MANAGED_PREFIX`] family) is ALREADY the KWin primary —
/// first-slot-wins support (§6.1) so a later exclusive session doesn't steal primary from the group's
/// first member. Best-effort: if kscreen reports no primary flag we treat it as "none" (the session
/// then sets itself primary — the pre-group behavior). Recent kscreen marks the primary with
/// `"priority": 1`; older builds used a `"primary": true` bool — accept either.
fn a_managed_output_is_primary() -> bool {
let Ok(out) = std::process::Command::new("kscreen-doctor")
.arg("-j")
.output()
else {
return false;
};
let Ok(doc) = serde_json::from_slice::<serde_json::Value>(&out.stdout) else {
return false;
};
doc.get("outputs")
.and_then(|o| o.as_array())
.map(|outs| {
outs.iter().any(|o| {
let managed = o
.get("name")
.and_then(|n| n.as_str())
.is_some_and(|n| n.starts_with(MANAGED_PREFIX));
let primary = o.get("primary").and_then(|p| p.as_bool()).unwrap_or(false)
|| o.get("priority").and_then(|p| p.as_u64()) == Some(1);
managed && primary
})
})
.unwrap_or(false)
}
/// Set `Virtual-punktfunk` primary and disable the bootstrap output(s) so the managed group becomes
/// the sole desktop (KWin re-homes plasmashell + windows onto it). Returns the disabled outputs for
/// the keepalive to re-enable on teardown. Best-effort: on failure, streaming continues (just possibly
/// showing only the wallpaper) rather than failing the session.
fn apply_virtual_primary() -> Vec<String> {
let ours = format!("Virtual-{VOUT_NAME}");
fn apply_virtual_primary(name: &str) -> Vec<String> {
let ours = format!("Virtual-{name}");
let kscreen = |args: &[String]| {
std::process::Command::new("kscreen-doctor")
.args(args)
@@ -271,15 +402,20 @@ fn apply_virtual_primary() -> Vec<String> {
.map(|s| s.success())
.unwrap_or(false)
};
// Make ours primary — KWin usually then re-homes the desktop and disables the bootstrap on its
// own. Let that settle, then belt-and-suspenders: disable anything still enabled besides ours so
// the streamed output is unambiguously the sole desktop regardless of KWin's implicit behaviour.
if !kscreen(&[format!("output.{ours}.primary")]) {
tracing::warn!(
"KWin: could not set the virtual output primary; client may see only the wallpaper"
);
// First-slot-wins (§6.1): only grab primary if no managed group member is primary yet — so a 2nd
// exclusive session joins as a secondary monitor of the shared desktop instead of stealing the
// shell off the 1st session's output. KWin usually then re-homes the desktop + disables the
// bootstrap on its own; the belt-and-suspenders disable below covers the rest.
if !a_managed_output_is_primary() {
if !kscreen(&[format!("output.{ours}.primary")]) {
tracing::warn!(
"KWin: could not set the virtual output primary; client may see only the wallpaper"
);
}
std::thread::sleep(Duration::from_millis(200));
}
std::thread::sleep(Duration::from_millis(200));
// Disable everything still enabled that ISN'T a managed group member (bootstrap / physical), so
// the group is unambiguously the desktop — never a sibling session's output (group-aware filter).
let others = other_enabled_outputs();
if !others.is_empty() {
let args: Vec<String> = others
@@ -292,29 +428,33 @@ fn apply_virtual_primary() -> Vec<String> {
others
}
/// **Primary** (Stage 2): make the streamed output the primary but KEEP the other outputs enabled
/// (don't disable the bootstrap/physical) — so the shell re-homes onto the streamed surface while a
/// physical screen stays usable. Nothing to restore on teardown (we disabled nothing).
fn apply_virtual_primary_only(name: &str) {
let ours = format!("Virtual-{name}");
let ok = std::process::Command::new("kscreen-doctor")
.arg(format!("output.{ours}.primary"))
.status()
.map(|s| s.success())
.unwrap_or(false);
if ok {
tracing::info!("KWin: streamed output set primary (physical outputs kept)");
} else {
tracing::warn!("KWin: could not set the virtual output primary");
}
}
/// 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.
/// 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
/// runs it once when the group's last member drops, BEFORE this keepalive is dropped.
struct StopGuard {
stop: Arc<AtomicBool>,
/// Bootstrap output(s) `apply_virtual_primary` disabled to make our streamed output the sole
/// desktop — re-enabled here FIRST, so KWin is never left with zero enabled outputs as our
/// output is reclaimed. Empty unless PUNKTFUNK_KWIN_VIRTUAL_PRIMARY is set.
restore: Vec<String>,
}
impl Drop for StopGuard {
fn drop(&mut self) {
if !self.restore.is_empty() {
let args: Vec<String> = self
.restore
.iter()
.map(|o| format!("output.{o}.enable"))
.collect();
let _ = std::process::Command::new("kscreen-doctor")
.args(&args)
.status();
std::thread::sleep(Duration::from_millis(200));
}
self.stop.store(true, Ordering::Relaxed);
}
}
@@ -388,10 +528,11 @@ impl Dispatch<ScreencastStream, ()> for State {
fn virtual_output_thread(
width: u32,
height: u32,
name: String,
setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>,
) {
if let Err(e) = run(width, height, &setup_tx, &stop) {
if let Err(e) = run(width, height, &name, &setup_tx, &stop) {
// If we never delivered a node id, report the failure to the waiting opener.
let _ = setup_tx.send(Err(format!("{e:#}")));
}
@@ -431,6 +572,7 @@ pub fn is_available() -> bool {
fn run(
width: u32,
height: u32,
name: &str,
setup_tx: &Sender<Result<u32, String>>,
stop: &AtomicBool,
) -> Result<()> {
@@ -453,7 +595,7 @@ fn run(
// Create the virtual output sized to the client, cursor composited into the stream.
let stream = screencast.stream_virtual_output(
VOUT_NAME.to_string(),
name.to_string(),
width as i32,
height as i32,
1.0, // scale (logical == physical)
@@ -522,3 +664,27 @@ fn run(
let _ = conn.flush();
Ok(())
}
#[cfg(test)]
mod tests {
use super::MANAGED_PREFIX;
/// Group-aware exclusive (§6.1): with two managed group members + a physical panel enabled,
/// exclusive disables ONLY the non-managed panel — never a sibling session's per-slot output
/// (the Stage-3 naming would otherwise make a 2nd exclusive session black out the 1st).
#[test]
fn exclusive_disables_only_non_managed() {
let enabled = [
"Virtual-punktfunk", // base name (shared identity)
"Virtual-punktfunk-1", // client A's per-slot output
"Virtual-punktfunk-7", // client B's per-slot output
"eDP-1", // a physical panel
];
let to_disable: Vec<&str> = enabled
.iter()
.copied()
.filter(|n| !n.starts_with(MANAGED_PREFIX))
.collect();
assert_eq!(to_disable, vec!["eDP-1"]);
}
}
@@ -42,11 +42,19 @@ const CURSOR_EMBEDDED: u32 = 1;
/// The Mutter virtual-display driver. Each [`create`](VirtualDisplay::create) spins up a
/// keepalive thread owning the D-Bus sessions behind the virtual monitor.
pub struct MutterDisplay;
pub struct MutterDisplay {
/// Whether this display is the FIRST of its group (§6.1) — set by the registry before `create`.
/// A later sibling **extends** into the already-exclusive desktop instead of re-applying the
/// sole-monitor config (which would disable the first session's virtual). Defaults true (a lone
/// session establishes topology as before).
first_in_group: bool,
}
impl MutterDisplay {
pub fn new() -> Result<Self> {
Ok(MutterDisplay)
Ok(MutterDisplay {
first_in_group: true,
})
}
}
@@ -64,13 +72,18 @@ impl VirtualDisplay for MutterDisplay {
"mutter"
}
fn set_first_in_group(&mut self, first: bool) {
self.first_in_group = first;
}
fn create(&mut self, mode: Mode) -> Result<VirtualOutput> {
let (setup_tx, setup_rx) = std::sync::mpsc::channel::<Result<u32, String>>();
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let first_in_group = self.first_in_group;
thread::Builder::new()
.name("punktfunk-mutter-vout".into())
.spawn(move || session_thread(setup_tx, stop_thread, mode))
.spawn(move || session_thread(setup_tx, stop_thread, mode, first_in_group))
.context("spawn Mutter virtual-output thread")?;
let node_id = match setup_rx.recv_timeout(Duration::from_secs(20)) {
@@ -104,8 +117,14 @@ impl Drop for StopGuard {
}
/// Keepalive thread: run the D-Bus handshake on a private tokio runtime, report the PipeWire
/// node id, then hold the connection until stopped.
fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>, mode: Mode) {
/// node id, then hold the connection until stopped. `first_in_group` gates the topology change (a
/// non-first sibling extends into the group's already-exclusive desktop instead of re-clobbering it).
fn session_thread(
setup_tx: Sender<Result<u32, String>>,
stop: Arc<AtomicBool>,
mode: Mode,
first_in_group: bool,
) {
let rt = match tokio::runtime::Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
@@ -118,9 +137,30 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
}
};
rt.block_on(async move {
// Opt-in: snapshot the monitor layout BEFORE the virtual output exists, so we can tell the
// new (virtual) connector apart and restore the layout on teardown. Best-effort.
let dc_pre = if virtual_primary_enabled() {
// Display-management topology (Stage 2): the console policy's level, resolved to a concrete
// value. `Extend` leaves the virtual output an extension (no config change); `Primary` makes
// it the primary monitor but keeps the physicals as secondaries; `Exclusive` makes it the
// SOLE output (physicals disabled). `Auto` never reaches here — it's resolved upstream.
use crate::vdisplay::policy::Topology;
let topo = crate::vdisplay::effective_topology();
let topo_policy = matches!(topo, Topology::Primary | Topology::Exclusive);
// Group-aware (§6.1): only the FIRST display of the group establishes the topology. A later
// sibling extends into the already-exclusive desktop — re-applying the sole-monitor config would
// disable the first session's virtual output (Mutter connectors are un-nameable, so we can't
// build a config that keeps all group virtuals; skipping is the safe choice). *Concurrent
// Mutter exclusive is on-glass-validation-pending; the APPLY_TEMPORARY revert when the FIRST
// session leaves under a live sibling is a documented residual (design §7).*
let want_config = first_in_group && topo_policy;
if topo_policy && !first_in_group {
tracing::info!(
"mutter: joining an existing display group — extending (the first session owns the \
exclusive/primary topology)"
);
}
let exclusive = matches!(topo, Topology::Exclusive);
// Snapshot the monitor layout BEFORE the virtual output exists (so we can tell the new
// connector apart and restore on teardown) whenever we're going to touch the topology.
let dc_pre = if want_config {
match display_config().await {
Ok(dc) => match get_state(&dc).await {
Ok(state) => Some((dc, state)),
@@ -152,8 +192,12 @@ fn session_thread(setup_tx: Sender<Result<u32, String>>, stop: Arc<AtomicBool>,
// monitor attached, the virtual output is an empty extended desktop — you stream only the
// wallpaper. Best-effort: any failure just logs and streaming continues unchanged.
if let Some((dc, pre)) = &dc_pre {
match make_virtual_primary(dc, mode, pre).await {
Ok(()) => tracing::info!("mutter: virtual output set as the primary monitor"),
match make_virtual_primary(dc, mode, pre, exclusive).await {
Ok(()) => tracing::info!(
exclusive,
"mutter: virtual output set as the primary monitor (physicals {})",
if exclusive { "disabled" } else { "kept" }
),
Err(e) => tracing::warn!(
"mutter: could not set the virtual output primary ({e:#}); streaming continues — the desktop may render on the physical monitor"
),
@@ -241,15 +285,16 @@ async fn connect(mode: Mode) -> Result<MutterSession> {
)
.await?;
// 3. The virtual monitor. By DEFAULT we let Mutter derive the refresh from the PipeWire
// framerate (it defaults the virtual monitor to 60 Hz) — universally safe.
// PUNKTFUNK_MUTTER_VIRTUAL_REFRESH=1 pins the client's exact WxH@Hz via RecordVirtual's "modes"
// (explicit size + refresh-rate; Mutter ≥ 47) for true >60 Hz — validated at 5120×1440@240 on
// Mutter 50 + NVIDIA. (A high-refresh virtual CRTC used to SIGSEGV gnome-shell on teardown; the
// stop-screencast-before-any-monitor-reconfig teardown below avoids that.)
// 3. The virtual monitor. For >60 Hz we pin the client's exact WxH@Hz via RecordVirtual's
// "modes" (explicit size + refresh-rate; Mutter ≥ 47) — validated at 5120×1440@240 on Mutter 50
// + NVIDIA. At ≤60 Hz we let Mutter derive the refresh from the PipeWire framerate (its 60 Hz
// default is already correct), so the custom-mode path only runs when it buys something.
// (A high-refresh virtual CRTC used to SIGSEGV gnome-shell on teardown, which is why this was
// once gated behind PUNKTFUNK_MUTTER_VIRTUAL_REFRESH; the stop-screencast-before-any-monitor-
// reconfig teardown below fixed the crash, so pinning the client's refresh is now the default.)
let mut rec: HashMap<&str, Value> = HashMap::new();
rec.insert("cursor-mode", Value::from(CURSOR_EMBEDDED));
if virtual_refresh_enabled() && mode.refresh_hz > 60 {
if mode.refresh_hz > 60 {
let mut vmode: HashMap<&str, Value> = HashMap::new();
vmode.insert("size", Value::from((mode.width, mode.height)));
vmode.insert("refresh-rate", Value::from(mode.refresh_hz as f64));
@@ -338,33 +383,6 @@ type CurrentState = (
type ApplyMon = (String, String, HashMap<String, Value<'static>>); // connector, mode_id, props
type ApplyLogical = (i32, i32, f64, u32, bool, Vec<ApplyMon>);
fn virtual_primary_enabled() -> bool {
std::env::var("PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
/// Opt-in: pin the virtual output to the client's exact refresh via RecordVirtual "modes" (true
/// above-60 Hz). Off by default — Mutter-derived 60 Hz is safe on every host; high-refresh virtual
/// CRTCs are validated on Mutter 50 + NVIDIA but behaviour can vary, so it stays opt-in. (The
/// teardown SIGSEGV that first motivated this gate is fixed by stopping the screencast before any
/// monitor-config change.)
fn virtual_refresh_enabled() -> bool {
std::env::var("PUNKTFUNK_MUTTER_VIRTUAL_REFRESH")
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
.unwrap_or(false)
}
/// A DisplayConfig proxy on its own session-bus connection (owned, so it stays alive for the
/// session — independent of the RemoteDesktop/ScreenCast connection).
async fn display_config() -> Result<zbus::Proxy<'static>> {
@@ -411,7 +429,12 @@ fn current_mode(state: &CurrentState, connector: &str) -> Option<(String, i32, i
/// which lands shortly after the node id), then make it the SOLE primary output (physicals
/// disabled for the session) so the cursor, windows, and keyboard focus stay on the streamed
/// surface. Restored on teardown.
async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentState) -> Result<()> {
async fn make_virtual_primary(
dc: &zbus::Proxy<'_>,
mode: Mode,
pre: &CurrentState,
exclusive: bool,
) -> Result<()> {
let pre_conns = connectors(pre);
let deadline = Instant::now() + Duration::from_secs(6);
loop {
@@ -437,7 +460,14 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta
let Some(vmode) = vmode else {
bail!("virtual monitor {vconn} has no usable mode yet");
};
let config = build_primary_config(&vconn, &vmode);
// Exclusive: the virtual output alone (physicals omitted → Mutter disables them).
// Primary: the virtual output primary at (0,0) PLUS the physicals kept as secondaries.
// (On a headless host with no physicals the two are identical.)
let config = if exclusive {
build_exclusive_config(&vconn, &vmode)
} else {
build_primary_keeping_physicals(&state, &vconn, &vmode, mode.width as i32)
};
let _: () = dc
.call(
"ApplyMonitorsConfig",
@@ -459,12 +489,12 @@ async fn make_virtual_primary(dc: &zbus::Proxy<'_>, mode: Mode, pre: &CurrentSta
}
}
/// The virtual output as the SOLE, primary monitor physical outputs are omitted, so Mutter
/// disables them for the session. This confines the cursor, windows, and keyboard focus to the
/// **Exclusive** — the virtual output as the SOLE, primary monitor: physical outputs are omitted, so
/// Mutter disables them for the session. This confines the cursor, windows, and keyboard focus to the
/// streamed surface; keeping the physical enabled as a *secondary* monitor instead lets relative
/// pointer motion and window focus wander onto it (invisible to the client — the cursor seems to
/// vanish). The physical layout is restored on teardown.
fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
fn build_exclusive_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
vec![(
0,
0,
@@ -474,3 +504,47 @@ fn build_primary_config(vconn: &str, vmode: &str) -> Vec<ApplyLogical> {
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
)]
}
/// **Primary** — the virtual output primary at `(0, 0)`, with every currently-active physical
/// monitor KEPT as a secondary (laid left-to-right past the virtual, each at its current mode). So
/// the shell + new windows land on the streamed surface, but the operator's physical screen stays
/// on. On a headless host (no physicals) this is identical to [`build_exclusive_config`].
///
/// *Physical-keep is unvalidated on-glass* — the lab boxes are headless (no attached display to keep
/// on); the layout math is conservative (append to the right) but wants a display-attached box.
fn build_primary_keeping_physicals(
state: &CurrentState,
vconn: &str,
vmode: &str,
virt_width: i32,
) -> Vec<ApplyLogical> {
let mut logicals: Vec<ApplyLogical> = vec![(
0,
0,
1.0,
0,
true,
vec![(vconn.to_string(), vmode.to_string(), HashMap::new())],
)];
// Append each physical (non-virtual) connector that has a usable current mode, to the right of
// the virtual output, as a non-primary secondary.
let mut x = virt_width.max(0);
for mon in &state.1 {
let conn = &mon.0 .0;
if conn == vconn {
continue;
}
if let Some((mode_id, w, _h)) = current_mode(state, conn) {
logicals.push((
x,
0,
1.0,
0,
false,
vec![(conn.clone(), mode_id, HashMap::new())],
));
x += w.max(0);
}
}
logicals
}
@@ -0,0 +1,623 @@
//! Virtual-display **management policy** — the user-configurable behavior surface for how virtual
//! displays are created, kept alive, and arranged (design: `design/display-management.md`).
//!
//! This is the pure config layer that sits **above** the per-compositor [`VirtualDisplay`](super)
//! backends: a small set of orthogonal options ([`DisplayPolicy`]) with safe defaults and named
//! [`Preset`]s, persisted to `<config>/display-settings.json` and editable from the web console.
//! The lifecycle/registry that *acts* on this policy lands in later stages; **Stage 0** (this file
//! plus the mgmt endpoints) stands up the surface and wires the two behaviors the existing code can
//! already express — the Windows monitor linger duration and the Linux "make the streamed output
//! the sole desktop" topology — through it.
//!
//! Precedence, mirroring the GPU preference (`console preference > env pin > default`): a present,
//! valid `display-settings.json` (console-written) **wins**; when it is absent the host keeps its
//! historical env-knob / default behavior untouched ([`DisplayPolicyStore::configured`] returns
//! `None`, and every Stage-0 call site falls back to exactly what it did before). The policy is
//! read at each acquire/teardown (file state, not a startup-frozen env var), so a console change
//! applies to the next connect without a host restart.
//!
//! The pure logic here — preset expansion, [`DisplayPolicy::effective`], the [`KeepAlive`] linger
//! resolution — is unit-tested; the store adds file I/O around it (the `gpu.rs` discipline:
//! private dir, temp-write + atomic rename, in-memory rollback on a failed write).
use std::collections::BTreeMap;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
/// How long a virtual display (and, on gamescope's bare spawn, the nested session + its game)
/// survives after the last client session detaches. Serialized as an object tagged on `mode`
/// (`{"mode":"off"}` / `{"mode":"duration","seconds":300}` / `{"mode":"forever"}`) so the web form
/// and the OpenAPI schema stay simple.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum KeepAlive {
/// Tear the display down at session end (today's default on every backend but Windows, which
/// lingers 10 s).
Off,
/// Keep the display for `seconds` after the last session leaves, then tear it down; a reconnect
/// inside the window reuses it.
Duration {
/// Linger window in seconds.
seconds: u32,
},
/// Keep the display until host shutdown or an explicit release (the `Pinned` lifecycle state).
/// **Not honored until the display-lifecycle stage** — rejected by the mgmt PUT at Stage 0.
Forever,
}
impl Default for KeepAlive {
fn default() -> Self {
// The historical Windows behavior, made explicit; the Linux backends had no linger and map
// `Off`/short-duration onto their (nonexistent) keep-alive as a no-op until the lifecycle stage.
KeepAlive::Duration { seconds: 10 }
}
}
/// Resolved linger for the display lifecycle: teardown immediately, after a fixed window, or never.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Linger {
/// Tear down as soon as the last session leaves.
Immediate,
/// Linger for this window, then tear down.
For(Duration),
/// Never auto-tear-down (Pinned).
Forever,
}
impl KeepAlive {
/// The [`Linger`] this keep-alive resolves to.
pub fn linger(self) -> Linger {
match self {
KeepAlive::Off => Linger::Immediate,
KeepAlive::Duration { seconds } => Linger::For(Duration::from_secs(seconds as u64)),
KeepAlive::Forever => Linger::Forever,
}
}
}
/// What the host does to the box's display topology while managed virtual displays are up.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum Topology {
/// Today's behavior, resolved per host at acquire time (see [`super::effective_topology`]):
/// exclusive on Windows and the auto-detected Linux desktop path, extend under an explicit
/// `PUNKTFUNK_COMPOSITOR` pin.
#[default]
Auto,
/// Add the virtual display(s); touch nothing else.
Extend,
/// Make the group's primary virtual display the OS primary; physical outputs stay enabled.
Primary,
/// The managed virtual displays become the only enabled outputs (physical outputs disabled,
/// restored on teardown).
Exclusive,
}
/// Admission when a *different* client connects while a display/session is already live and asks for
/// a different mode. Stored at Stage 0; enforced from the mode-conflict admission stage.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "snake_case")]
pub enum ModeConflict {
/// Give the new client its own virtual display on the same desktop (today's Linux multi-view).
#[default]
Separate,
/// Stop the existing session(s), tear down / reconfigure, serve the new client.
Steal,
/// Admit the new client at the live display's mode (the honest-downgrade convention).
Join,
/// Refuse the new client with a clear handshake error.
Reject,
}
/// Stable display identity, so desktop environments persist per-display config (KDE scaling). Stored
/// at Stage 0; carriers wired from the identity stage.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Identity {
/// One identity for everything (today's Linux behavior).
Shared,
/// One identity per paired client cert fingerprint (today's Windows behavior).
#[default]
PerClient,
/// One identity per (client, resolution) — distinct scaling per resolution, at the cost of
/// identity slots.
PerClientMode,
}
/// How group members are arranged in the desktop coordinate space. Stored at Stage 0; applied from
/// the multi-monitor stage.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum LayoutMode {
/// Left-to-right in acquire order, top-aligned (deterministic default).
#[default]
AutoRow,
/// Per-identity-slot offsets from [`Layout::positions`] (console-arranged).
Manual,
}
/// A desktop-space offset for a display (top-left origin).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Position {
pub x: i32,
pub y: i32,
}
/// Group layout: the arrangement mode plus, for [`LayoutMode::Manual`], per-slot offsets keyed by
/// identity-slot id (string keys for stable JSON).
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct Layout {
#[serde(default)]
pub mode: LayoutMode,
#[serde(default)]
pub positions: BTreeMap<String, Position>,
}
/// A named bundle of the fields below. `Custom` (the default) means the explicit fields rule; any
/// other preset ignores the stored fields and expands to its own ([`DisplayPolicy::effective`]).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "kebab-case")]
pub enum Preset {
/// The explicit fields below define the policy.
#[default]
Custom,
/// Today's behavior, made explicit.
Default,
/// Dedicated headless/couch box: displays + game survive disconnects; whoever connects takes over.
GamingRig,
/// A desktop someone also uses physically: never blank the real monitors, never keep ghosts.
SharedDesktop,
/// One user at a time with fast reattach; a second user is told the box is busy.
Hotdesk,
/// The multi-monitor daily driver: manual arrangement, per-client identity, exclusive.
Workstation,
}
/// The user-facing display-management policy — what `display-settings.json` holds and what the mgmt
/// API GETs/PUTs. When [`preset`](Self::preset) is not [`Preset::Custom`] the explicit fields are
/// ignored (the console writes one or the other); [`effective`](Self::effective) resolves both to a
/// single [`EffectivePolicy`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct DisplayPolicy {
/// Schema version (currently 1) — lets a future field addition migrate rather than reject.
#[serde(default = "one")]
pub version: u32,
#[serde(default)]
pub preset: Preset,
#[serde(default)]
pub keep_alive: KeepAlive,
#[serde(default)]
pub topology: Topology,
#[serde(default)]
pub mode_conflict: ModeConflict,
#[serde(default)]
pub identity: Identity,
#[serde(default)]
pub layout: Layout,
/// Upper bound on simultaneously-live virtual displays (clamped to `1..=16` on write).
#[serde(default = "default_max_displays")]
pub max_displays: u32,
}
fn one() -> u32 {
1
}
fn default_max_displays() -> u32 {
4
}
impl Default for DisplayPolicy {
fn default() -> Self {
// Bit-for-bit today's behavior (the `default` preset expanded), so an unconfigured host reads
// the same policy the Stage-0 call sites already produce.
DisplayPolicy {
version: 1,
preset: Preset::Custom,
keep_alive: KeepAlive::default(),
topology: Topology::Auto,
mode_conflict: ModeConflict::default(),
identity: Identity::default(),
layout: Layout::default(),
max_displays: 4,
}
}
}
/// The six resolved fields after preset expansion — what the lifecycle/registry and the Stage-0 call
/// sites read, and what the mgmt API echoes as the "currently in force" policy. Pure output of
/// [`DisplayPolicy::effective`].
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
pub struct EffectivePolicy {
pub keep_alive: KeepAlive,
pub topology: Topology,
pub mode_conflict: ModeConflict,
pub identity: Identity,
pub layout: Layout,
pub max_displays: u32,
}
impl DisplayPolicy {
/// Resolve to the [`EffectivePolicy`]: a named preset expands to its bundle; `Custom` uses the
/// explicit fields. Pure — the single source of truth shared by the preset docs and the runtime.
pub fn effective(&self) -> EffectivePolicy {
if let Some(mut e) = preset_fields(self.preset) {
// A preset fixes the six behavior fields but honors an explicit manual layout table
// (positions are data, not behavior — the `workstation` preset only sets the *mode*).
if self.preset == Preset::Workstation && !self.layout.positions.is_empty() {
e.layout.positions = self.layout.positions.clone();
}
e
} else {
EffectivePolicy {
keep_alive: self.keep_alive,
topology: self.topology,
mode_conflict: self.mode_conflict,
identity: self.identity,
layout: self.layout.clone(),
max_displays: self.max_displays,
}
}
}
/// Clamp fields to their valid ranges (called on write). `max_displays` to `1..=16` (the
/// pf-vdisplay connector ceiling / a sane Linux bound).
pub fn sanitized(mut self) -> Self {
self.version = 1;
self.max_displays = self.max_displays.clamp(1, 16);
self
}
}
impl EffectivePolicy {
/// Build a persistable `Custom` [`DisplayPolicy`] that keeps THIS effective behavior but replaces
/// the arrangement with a **manual** layout at `positions` — the `/display/layout` endpoint's
/// transform, factored out pure so arranging displays stays orthogonal to the other axes and is
/// unit-tested without touching the global store. (`Custom` so the explicit fields — incl. the new
/// layout — rule; a named preset would ignore them.)
pub fn with_manual_layout(&self, positions: BTreeMap<String, Position>) -> DisplayPolicy {
DisplayPolicy {
version: 1,
preset: Preset::Custom,
keep_alive: self.keep_alive,
topology: self.topology,
mode_conflict: self.mode_conflict,
identity: self.identity,
layout: Layout {
mode: LayoutMode::Manual,
positions,
},
max_displays: self.max_displays,
}
}
}
/// The field bundle a named preset expands to; `None` for [`Preset::Custom`]. The single expansion
/// table — the docs' preset table mirrors this and the `presets_match_doc` test guards the shape.
pub fn preset_fields(preset: Preset) -> Option<EffectivePolicy> {
let base = |keep_alive, topology, mode_conflict, identity, layout_mode| EffectivePolicy {
keep_alive,
topology,
mode_conflict,
identity,
layout: Layout {
mode: layout_mode,
positions: BTreeMap::new(),
},
max_displays: 4,
};
Some(match preset {
Preset::Custom => return None,
Preset::Default => base(
KeepAlive::Duration { seconds: 10 },
Topology::Auto,
ModeConflict::Separate,
Identity::PerClient,
LayoutMode::AutoRow,
),
Preset::GamingRig => base(
KeepAlive::Forever,
Topology::Exclusive,
ModeConflict::Steal,
Identity::PerClient,
LayoutMode::AutoRow,
),
Preset::SharedDesktop => base(
KeepAlive::Off,
Topology::Extend,
ModeConflict::Separate,
Identity::PerClient,
LayoutMode::AutoRow,
),
Preset::Hotdesk => base(
KeepAlive::Duration { seconds: 300 },
Topology::Exclusive,
ModeConflict::Reject,
Identity::PerClientMode,
LayoutMode::AutoRow,
),
Preset::Workstation => base(
KeepAlive::Duration { seconds: 300 },
Topology::Exclusive,
ModeConflict::Separate,
Identity::PerClient,
LayoutMode::Manual,
),
})
}
/// The persisted policy store: the loaded file value (or `None` when no file exists) behind its
/// JSON path. Mirrors [`crate::gpu::GpuPrefStore`] — private dir, temp-write + atomic rename,
/// in-memory rollback if the disk write fails.
pub struct DisplayPolicyStore {
path: PathBuf,
/// `Some` only when a valid `display-settings.json` was loaded / written — the "console has
/// configured this host" signal that gates whether Stage-0 call sites override their historical
/// env/default behavior.
cur: Mutex<Option<DisplayPolicy>>,
}
impl DisplayPolicyStore {
/// Load from `path`. A missing file ⇒ unconfigured (`None`); a corrupt file ⇒ unconfigured with a
/// warning (never fail host startup over a settings file).
pub fn load_from(path: PathBuf) -> Self {
let cur = match std::fs::read(&path) {
Ok(bytes) => match serde_json::from_slice::<DisplayPolicy>(&bytes) {
Ok(p) => Some(p),
Err(e) => {
tracing::warn!(path = %path.display(),
"display-settings.json unreadable — using built-in defaults: {e}");
None
}
},
Err(_) => None,
};
DisplayPolicyStore {
path,
cur: Mutex::new(cur),
}
}
/// The stored policy, or [`DisplayPolicy::default`] when unconfigured (for the mgmt GET).
pub fn get(&self) -> DisplayPolicy {
self.cur.lock().unwrap().clone().unwrap_or_default()
}
/// The console-configured policy, or `None` when no settings file exists. Stage-0 call sites use
/// this to decide whether to override their historical behavior (`None` ⇒ leave it untouched).
pub fn configured(&self) -> Option<DisplayPolicy> {
self.cur.lock().unwrap().clone()
}
/// The effective (preset-expanded) policy the console configured, or `None` when unconfigured.
pub fn configured_effective(&self) -> Option<EffectivePolicy> {
self.configured().map(|p| p.effective())
}
/// Persist + adopt a new policy (sanitized first). The in-memory value changes only if the disk
/// write succeeds, so a full disk can't leave memory and file disagreeing.
pub fn set(&self, policy: DisplayPolicy) -> Result<()> {
let policy = policy.sanitized();
if let Some(dir) = self.path.parent() {
crate::gamestream::create_private_dir(dir)?;
}
let tmp = self.path.with_extension("json.tmp");
crate::gamestream::write_secret_file(&tmp, &serde_json::to_vec_pretty(&policy)?)?;
std::fs::rename(&tmp, &self.path)?;
*self.cur.lock().unwrap() = Some(policy);
Ok(())
}
}
/// The process-wide display-policy store (config-dir file), loaded once on first access — the same
/// global-accessor shape as [`crate::gpu::prefs`], because display setup happens deep in the
/// capture/vdisplay path where no app state is threaded.
pub fn prefs() -> &'static DisplayPolicyStore {
static STORE: OnceLock<DisplayPolicyStore> = OnceLock::new();
STORE.get_or_init(|| {
DisplayPolicyStore::load_from(crate::gamestream::config_dir().join("display-settings.json"))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keep_alive_serializes_tagged_on_mode() {
assert_eq!(
serde_json::to_value(KeepAlive::Duration { seconds: 300 }).unwrap(),
serde_json::json!({ "mode": "duration", "seconds": 300 })
);
assert_eq!(
serde_json::to_value(KeepAlive::Off).unwrap(),
serde_json::json!({ "mode": "off" })
);
assert_eq!(
serde_json::to_value(KeepAlive::Forever).unwrap(),
serde_json::json!({ "mode": "forever" })
);
// Round-trips.
for k in [
KeepAlive::Off,
KeepAlive::Duration { seconds: 42 },
KeepAlive::Forever,
] {
let s = serde_json::to_string(&k).unwrap();
assert_eq!(serde_json::from_str::<KeepAlive>(&s).unwrap(), k);
}
}
#[test]
fn keep_alive_linger_resolution() {
assert_eq!(KeepAlive::Off.linger(), Linger::Immediate);
assert_eq!(
KeepAlive::Duration { seconds: 30 }.linger(),
Linger::For(Duration::from_secs(30))
);
assert_eq!(KeepAlive::Forever.linger(), Linger::Forever);
}
#[test]
fn default_policy_is_todays_behavior() {
let e = DisplayPolicy::default().effective();
assert_eq!(e.keep_alive, KeepAlive::Duration { seconds: 10 });
assert_eq!(e.topology, Topology::Auto);
assert_eq!(e.mode_conflict, ModeConflict::Separate);
assert_eq!(e.identity, Identity::PerClient);
assert_eq!(e.layout.mode, LayoutMode::AutoRow);
}
#[test]
fn custom_uses_explicit_fields_presets_override_them() {
// Custom: explicit fields flow through.
let p = DisplayPolicy {
preset: Preset::Custom,
keep_alive: KeepAlive::Off,
topology: Topology::Extend,
..DisplayPolicy::default()
};
assert_eq!(p.effective().keep_alive, KeepAlive::Off);
assert_eq!(p.effective().topology, Topology::Extend);
// A named preset ignores the explicit fields.
let p = DisplayPolicy {
preset: Preset::GamingRig,
keep_alive: KeepAlive::Off, // ignored
topology: Topology::Extend, // ignored
..DisplayPolicy::default()
};
let e = p.effective();
assert_eq!(e.keep_alive, KeepAlive::Forever);
assert_eq!(e.topology, Topology::Exclusive);
assert_eq!(e.mode_conflict, ModeConflict::Steal);
}
#[test]
fn workstation_preset_keeps_manual_layout_positions() {
let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 2560, y: 0 });
let p = DisplayPolicy {
preset: Preset::Workstation,
layout: Layout {
mode: LayoutMode::AutoRow, // preset forces Manual regardless
positions,
},
..DisplayPolicy::default()
};
let e = p.effective();
assert_eq!(e.layout.mode, LayoutMode::Manual);
assert_eq!(
e.layout.positions.get("1"),
Some(&Position { x: 2560, y: 0 })
);
}
#[test]
fn every_preset_expands() {
for preset in [
Preset::Default,
Preset::GamingRig,
Preset::SharedDesktop,
Preset::Hotdesk,
Preset::Workstation,
] {
assert!(preset_fields(preset).is_some(), "{preset:?} must expand");
}
assert!(preset_fields(Preset::Custom).is_none());
}
#[test]
fn sanitize_clamps_max_displays_and_pins_version() {
let p = DisplayPolicy {
version: 99,
max_displays: 0,
..DisplayPolicy::default()
}
.sanitized();
assert_eq!(p.version, 1);
assert_eq!(p.max_displays, 1);
let p = DisplayPolicy {
max_displays: 999,
..DisplayPolicy::default()
}
.sanitized();
assert_eq!(p.max_displays, 16);
}
#[test]
fn with_manual_layout_preserves_behavior_and_sets_positions() {
// Start from a preset's effective behavior (workstation: 5-min linger, exclusive, per-client).
let eff = DisplayPolicy {
preset: Preset::Workstation,
..DisplayPolicy::default()
}
.effective();
let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 0, y: 0 });
positions.insert("7".to_string(), Position { x: 2560, y: 0 });
let p = eff.with_manual_layout(positions);
// Preset drops to Custom so the explicit fields (incl. the layout) rule…
assert_eq!(p.preset, Preset::Custom);
// …every other behavior axis is preserved verbatim…
assert_eq!(p.keep_alive, eff.keep_alive);
assert_eq!(p.topology, eff.topology);
assert_eq!(p.mode_conflict, eff.mode_conflict);
assert_eq!(p.identity, eff.identity);
assert_eq!(p.max_displays, eff.max_displays);
// …and the arrangement is the manual layout we asked for, surviving the effective round-trip.
let e2 = p.effective();
assert_eq!(e2.layout.mode, LayoutMode::Manual);
let want = Position { x: 2560, y: 0 };
assert_eq!(e2.layout.positions.get("7"), Some(&want));
}
#[test]
fn partial_json_fills_defaults() {
// A hand-written file with only a couple of fields loads, the rest defaulting.
let p: DisplayPolicy =
serde_json::from_str(r#"{ "preset": "custom", "max_displays": 2 }"#).unwrap();
assert_eq!(p.max_displays, 2);
assert_eq!(p.keep_alive, KeepAlive::default());
assert_eq!(p.topology, Topology::Auto);
assert_eq!(p.version, 1);
}
#[test]
fn store_roundtrips_and_gates_on_file_presence() {
let dir = std::env::temp_dir().join(format!("pf-disp-{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("display-settings.json");
let _ = std::fs::remove_file(&path);
let store = DisplayPolicyStore::load_from(path.clone());
// Unconfigured: get() yields defaults, configured() is None.
assert!(store.configured().is_none());
assert_eq!(store.get(), DisplayPolicy::default());
// After a write the file gates flip to configured.
let want = DisplayPolicy {
preset: Preset::SharedDesktop,
..DisplayPolicy::default()
};
store.set(want.clone()).unwrap();
assert_eq!(
store.configured().as_ref().map(|p| p.preset),
Some(Preset::SharedDesktop)
);
assert_eq!(
store.configured_effective().unwrap().keep_alive,
KeepAlive::Off
);
// A fresh store reading the same path sees the persisted value.
let reopened = DisplayPolicyStore::load_from(path.clone());
assert_eq!(reopened.configured().unwrap().preset, Preset::SharedDesktop);
let _ = std::fs::remove_file(&path);
}
}
@@ -0,0 +1,940 @@
//! Host-lifetime **virtual-display registry** (design: `design/display-management.md` §3/§7): the
//! owner of the display lifecycle, so a display can outlive the session that created it (keep-alive)
//! and the management API can list + release kept displays.
//!
//! **Windows** already owns its lifecycle in [`super::manager::VirtualDisplayManager`] (one shared
//! IddCx monitor, refcounted, lingering); [`acquire`] there is a pass-through to `vd.create` (the
//! manager does the leasing), and [`snapshot`]/[`release`] read/control it.
//!
//! **Linux** gains a per-session **pool** here, driven by the pure [`super::lifecycle`] machine. The
//! key enabling fact: KWin / Mutter / gamescope put their capture node on the *default* PipeWire
//! daemon (`VirtualOutput::remote_fd == None`), reachable by `node_id` alone — so keeping the
//! backend's keepalive alive keeps the node alive, and a reconnect just re-attaches a fresh PipeWire
//! consumer to the same `node_id`. No fd dup / re-open needed. wlroots (`remote_fd == Some`, the
//! sandboxed xdpw portal) can't be kept without re-opening the portal fd per attach, so it is passed
//! through unchanged (teardown-on-drop, today's behavior) until that fresh-portal-capture re-attach
//! lands — a runtime gate on `remote_fd.is_some()`.
//!
//! The ownership split: the session's capturer no longer owns the real keepalive — the registry does.
//! [`acquire`] hands the session a `VirtualOutput` whose `keepalive` is a lightweight, gen-stamped
//! `DisplayLease` (mirrors the Windows `MonitorLease`); dropping it releases the registry refcount,
//! and the lifecycle machine decides linger / teardown. `capture_virtual_output`'s signature is
//! unchanged — it just holds a lease instead of the real keepalive.
use anyhow::Result;
/// One live or kept virtual display, for the mgmt snapshot.
#[derive(Clone, Debug)]
pub struct DisplayInfo {
/// A stable-enough id for the `/display/release` slot argument (the owner's generation stamp).
pub slot: u64,
/// Backend name (`"pf-vdisplay"`, `"kwin"`, `"mutter"`, …).
pub backend: String,
/// `(width, height, refresh_hz)`.
pub mode: (u32, u32, u32),
/// `"active"` | `"lingering"` | `"pinned"`.
pub state: String,
/// Milliseconds until a lingering display is torn down (`None` when active/pinned).
pub expires_in_ms: Option<u64>,
/// Live sessions holding the display.
pub sessions: u32,
/// Short client label (cert-fp prefix / peer), when the owner tracks it.
pub client: Option<String>,
/// Display **group** (shared desktop) id (design §6.1): Linux gives every backend session one
/// group; Windows is single-group (`1`).
pub group: u32,
/// This display's ordinal within its group, in acquire order (0-based) — the §6A "which monitor".
pub display_index: u32,
/// Desktop-space top-left origin `(x, y)` (design §6.2): auto-row, or the console's manual
/// arrangement when configured.
pub position: (i32, i32),
/// The stable per-client identity slot keying this display's persistent config + manual layout
/// (§5.4); `None` for a shared/anonymous identity.
pub identity_slot: Option<u32>,
/// The effective topology for this display's group (`"extend"` | `"primary"` | `"exclusive"`).
pub topology: String,
}
/// The live display set for the mgmt `/display/state` endpoint.
#[derive(Clone, Debug, Default)]
pub struct Snapshot {
pub displays: Vec<DisplayInfo>,
}
/// The effective display topology as a lowercase string for the snapshot (`effective_topology`
/// resolves `Auto` away; the arm is defensive).
fn topology_str() -> String {
use super::policy::Topology;
match super::effective_topology() {
Topology::Extend => "extend",
Topology::Primary => "primary",
Topology::Exclusive => "exclusive",
Topology::Auto => "auto",
}
.to_string()
}
/// Acquire a virtual display for a session: reuse a kept (lingering/pinned) display of the same
/// backend + mode if one exists, else create a fresh one. Returns a [`VirtualOutput`](super::VirtualOutput)
/// the capturer consumes as before — but its `keepalive` is a registry lease, so the *display*
/// outlives the capturer per the keep-alive policy.
///
/// Windows delegates to the [`manager`](super::manager) via `vd.create` (unchanged); Linux uses the
/// pool below; other platforms pass through.
/// `quit` is the session's deliberate-quit flag: when the session ends with it set (the client closed
/// with the quit application code — a user "stop", not a network drop), the display is torn down
/// **immediately**, skipping the keep-alive linger. A bare disconnect leaves it `false` → normal linger.
pub fn acquire(
vd: &mut Box<dyn super::VirtualDisplay>,
mode: super::Mode,
quit: std::sync::Arc<std::sync::atomic::AtomicBool>,
) -> Result<super::VirtualOutput> {
#[cfg(target_os = "linux")]
{
linux::acquire(vd, mode, quit)
}
#[cfg(not(target_os = "linux"))]
{
// Windows leases in the manager (its own linger); the deliberate-quit skip is not wired
// through there yet, so the flag is accepted but unused off Linux.
let _ = quit;
vd.create(mode)
}
}
/// Snapshot the host's managed virtual displays. Cheap + side-effect-free (a state-lock read);
/// safe per management request.
pub fn snapshot() -> Snapshot {
#[cfg(target_os = "windows")]
{
// Windows is single-monitor at this stage (§6.6 multi-monitor is Stage 7): one group, index 0,
// origin. Its per-client identity lives in the driver (EDID serial / ConnectorIndex), not
// surfaced here yet.
let displays = super::manager::snapshot()
.map(|i| DisplayInfo {
slot: i.gen,
backend: i.backend.to_string(),
mode: i.mode,
state: i.state.to_string(),
expires_in_ms: i.expires_in_ms,
sessions: i.sessions,
client: None,
group: 1,
display_index: 0,
position: (0, 0),
identity_slot: None,
topology: topology_str(),
})
.into_iter()
.collect();
Snapshot { displays }
}
#[cfg(target_os = "linux")]
{
Snapshot {
displays: linux::snapshot(),
}
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
Snapshot::default()
}
}
/// Force-release kept (lingering/pinned) displays now — the `/display/release` endpoint. `slot`
/// selects one by [`DisplayInfo::slot`]; `None` releases every kept display. Active displays are
/// refused (releasing a display with live sessions is session management). Returns the number
/// released.
pub fn release(slot: Option<u64>) -> usize {
#[cfg(target_os = "windows")]
{
// Windows manages a single shared monitor at Stage 1, so `slot` is moot — release the one
// lingering monitor if present. (Multi-monitor gives `slot` meaning later.)
let _ = slot;
usize::from(super::manager::force_release())
}
#[cfg(target_os = "linux")]
{
linux::force_release(slot)
}
#[cfg(not(any(target_os = "windows", target_os = "linux")))]
{
let _ = slot;
0
}
}
// ---------------------------------------------------------------------------------------------
// Linux keep-alive pool
// ---------------------------------------------------------------------------------------------
#[cfg(target_os = "linux")]
mod linux {
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, Once, OnceLock};
use std::time::{Duration, Instant};
use anyhow::Result;
use super::DisplayInfo;
use crate::vdisplay::lifecycle::{self, Release};
use crate::vdisplay::policy::{self, Layout, Linger};
use crate::vdisplay::{Mode, VirtualDisplay, VirtualOutput};
/// One pooled display: the lifecycle state + the backend's REAL keepalive (kept alive here so the
/// compositor output — and thus its PipeWire `node_id` — survives past the session), plus the
/// capture coordinates a reconnecting session needs.
struct Entry {
life: lifecycle::State,
/// The backend's keepalive (KWin Wayland conn / Mutter D-Bus session / gamescope child). Its
/// `Drop` releases the compositor output — so it is dropped only on teardown/expiry.
keepalive: Box<dyn Send>,
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
mode: Mode,
backend: &'static str,
/// The identity slot the backend resolved for this display (KWin per-slot naming; `None` for
/// shared/anonymous or a backend with no per-client identity) — keys the group arrangement +
/// the `/display/state` slot. Captured at create; kept across a keep-alive reuse.
identity_slot: Option<u32>,
/// The topology-restore action for this display's GROUP (design §6.1): re-enable the physical
/// outputs an `exclusive` topology disabled. At most ONE entry per group carries it (the first
/// exclusive session); on teardown it hands off to a surviving sibling, and only runs when the
/// group's last member drops. `None` for extend/primary and non-first / non-exclusive members.
topology_restore: Option<Restore>,
/// Generation stamp: a [`DisplayLease`] only releases if its gen still matches (a stale lease
/// — its entry was reused + re-stamped — is a no-op).
gen: u64,
}
/// A per-group topology-restore action (see [`Entry::topology_restore`]).
type Restore = Box<dyn FnOnce() + Send>;
/// Hand off a torn-down display's topology restore (design §6.1 — per-group restore): if a
/// same-group (backend) sibling survives in `remaining`, MOVE the restore onto it (a later teardown
/// runs it); if the group is now empty, RETURN the action so the caller runs it (before dropping the
/// reclaimed display's keepalive, so the physical is re-enabled while our output still exists —
/// the compositor never sees zero outputs). `None` in → `None` out.
fn hand_off_restore(
remaining: &mut [Entry],
backend: &'static str,
restore: Option<Restore>,
) -> Option<Restore> {
let action = restore?;
// At most one restore per group, so any surviving sibling has `None` to receive it.
match remaining.iter_mut().find(|e| e.backend == backend) {
Some(sibling) => {
sibling.topology_restore = Some(action);
None
}
None => Some(action), // group empty → run it now
}
}
struct Reg {
entries: Mutex<Vec<Entry>>,
gen: AtomicU64,
}
static REG: OnceLock<Reg> = OnceLock::new();
fn reg() -> &'static Reg {
REG.get_or_init(|| Reg {
entries: Mutex::new(Vec::new()),
gen: AtomicU64::new(1),
})
}
/// The linger resolution for Linux: the console policy's `keep_alive` when configured, else
/// **Immediate** (today's behavior — a Linux disconnect tears the output down at once).
fn linger() -> Linger {
policy::prefs()
.configured_effective()
.map(|e| e.keep_alive.linger())
.unwrap_or(Linger::Immediate)
}
/// Remove entries whose linger deadline has passed, returning them so the caller drops (tears
/// them down) *after* releasing the lock — a backend keepalive `Drop` (Mutter D-Bus Stop) can
/// block, and holding the pool lock across it would stall every other acquire/release. Each
/// expired entry's topology restore is [handed off](hand_off_restore) to a surviving group sibling,
/// or collected into the returned `restores` when its group empties (run before the entries drop).
fn take_expired(entries: &mut Vec<Entry>, now: Instant) -> (Vec<Entry>, Vec<Restore>) {
let mut expired = Vec::new();
let mut restores = Vec::new();
let mut i = 0;
while i < entries.len() {
if entries[i].life.poll_expiry(now) {
let mut e = entries.remove(i);
let backend = e.backend;
if let Some(r) = hand_off_restore(entries, backend, e.topology_restore.take()) {
restores.push(r);
}
expired.push(e);
} else {
i += 1;
}
}
(expired, restores)
}
/// Background thread (started once): reap lingering displays past their deadline.
fn ensure_timer() {
static ONCE: Once = Once::new();
ONCE.call_once(|| {
let _ = std::thread::Builder::new()
.name("vdisplay-linger".into())
.spawn(|| loop {
std::thread::sleep(Duration::from_millis(500));
let (expired, restores) = {
let mut es = reg().entries.lock().unwrap();
take_expired(&mut es, Instant::now())
};
// Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock.
for restore in restores {
restore();
}
for e in expired {
tracing::info!(
backend = e.backend,
"virtual display: linger expired — torn down"
);
drop(e); // outside the lock
}
});
});
}
/// Build the session-facing [`VirtualOutput`]: the kept node + a fresh gen-stamped lease. Only
/// the poolable (`remote_fd == None`) backends reach here, so `remote_fd` is always `None`.
fn output_for(
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
gen: u64,
quit: Arc<AtomicBool>,
) -> VirtualOutput {
VirtualOutput {
node_id,
remote_fd: None,
preferred_mode,
keepalive: Box::new(DisplayLease { gen, quit }),
}
}
pub(super) fn acquire(
vd: &mut Box<dyn VirtualDisplay>,
mode: Mode,
quit: Arc<AtomicBool>,
) -> Result<VirtualOutput> {
ensure_timer();
let backend = vd.name();
let r = reg();
// Reap expired first (run any group restores + drop outside the lock).
let (expired, restores) = {
let mut es = r.entries.lock().unwrap();
take_expired(&mut es, Instant::now())
};
for restore in restores {
restore();
}
drop(expired);
// Reuse: a kept (lingering/pinned) display of the same backend + mode. A reconnecting session
// re-attaches a fresh PipeWire consumer to the still-live `node_id`.
{
let mut es = r.entries.lock().unwrap();
if let Some(e) = es.iter_mut().find(|e| {
matches!(
e.life,
lifecycle::State::Lingering { .. } | lifecycle::State::Pinned
) && e.backend == backend
&& e.mode == mode
}) {
// Lingering/Pinned → Active (Acquire::Reuse); side effect matters, value is known.
e.life.acquire();
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
e.gen = gen;
let out = output_for(e.node_id, e.preferred_mode, gen, quit);
tracing::info!(
backend,
node_id = e.node_id,
"virtual display reused (keep-alive reconnect)"
);
return Ok(out);
}
}
// Tell the backend whether it's the FIRST display of its group (no same-backend sibling live,
// §6.1) — so a topology-establishing backend (Mutter exclusive) extends into an already-exclusive
// desktop rather than re-clobbering the first session's virtual. Best-effort (a concurrent create
// is a narrow race); single-session is always `first == true` → today's behavior.
let first_in_group = {
let es = r.entries.lock().unwrap();
!es.iter().any(|e| e.backend == backend)
};
vd.set_first_in_group(first_in_group);
// Create a fresh display (NOT under the lock — `vd.create` blocks + spawns threads).
let real = vd.create(mode)?;
// The identity slot the backend just resolved (KWin per-slot naming; `None` elsewhere) — keys
// the group arrangement (manual per-slot positions) + the state slot.
let identity_slot = vd.last_identity_slot();
// wlroots (remote_fd = Some, sandboxed xdpw portal) can't be kept without re-opening the
// portal fd per attach — pass it through unchanged (capturer owns it, teardown on drop). The
// poolable backends put their node on the default daemon (remote_fd = None).
if real.remote_fd.is_some() {
tracing::debug!(
backend,
"virtual display not poolable (portal fd) — keep-alive off for this backend"
);
return Ok(real);
}
let node_id = real.node_id;
let preferred_mode = real.preferred_mode;
// The backend's topology-restore action (KWin `exclusive` → re-enable the disabled physicals),
// lifted into the group so it runs once when the group's last member drops (§6.1), not at this
// session's teardown. `None` for non-exclusive / non-first / backends whose topology auto-reverts.
let topology_restore = vd.take_topology_restore();
let gen = r.gen.fetch_add(1, Ordering::Relaxed);
let mut life = lifecycle::State::default();
life.acquire(); // Idle → Active{refs:1} (Acquire::Create)
let entry = Entry {
life,
keepalive: real.keepalive,
node_id,
preferred_mode,
mode,
backend,
identity_slot,
topology_restore,
gen,
};
// Compute this new display's position in its group (design §6.2) BEFORE pushing, then push
// under the same lock: the group is the same-backend entries; the new one appends last
// (rightmost under auto-row). `position_for_new` is pure; the lock is held only across it
// (I/O-free) — the backend apply is below, outside the lock.
let position = {
use crate::vdisplay::layout::Member;
let layout_policy = policy::prefs()
.configured_effective()
.map(|e| e.layout)
.unwrap_or_default();
let mut es = r.entries.lock().unwrap();
// Same-group members (design §6.1): same backend for a shared desktop, but each gamescope
// spawn is its own group, so a new gamescope never auto-rows against another.
let new_group = group_key(backend, gen);
let existing: Vec<(u64, Member)> = es
.iter()
.filter(|e| group_key(e.backend, e.gen) == new_group)
.map(|e| {
(
e.gen,
Member {
identity_slot: e.identity_slot,
width: e.mode.width as i32,
},
)
})
.collect();
let new_member = Member {
identity_slot,
width: mode.width as i32,
};
let pos = position_for_new(existing, new_member, &layout_policy);
es.push(entry);
pos
};
// Place the new output (design §6.2), best-effort, OUTSIDE the lock (kscreen blocks). Skip the
// desktop origin `(0, 0)` — it's the compositor default, so a single-display / first-of-group
// session (and every non-KWin backend, which no-ops `apply_position`) issues no positioning at
// all: the historical single-display path is untouched. *On-glass-validation-pending.*
if (position.x, position.y) != (0, 0) {
vd.apply_position(position.x, position.y);
}
Ok(output_for(node_id, preferred_mode, gen, quit))
}
/// The [`DisplayLease`] `Drop` path: release the session's hold on the pooled display. The
/// lifecycle machine decides linger / pin / teardown; a torn-down entry's keepalive drops *after*
/// the lock is released.
fn release(gen: u64, force_immediate: bool) {
let Some(r) = REG.get() else { return };
// A deliberate quit (the client closed with the quit code — a user "stop") tears the display
// down NOW, overriding the keep-alive linger; a bare disconnect honors the policy.
let linger = if force_immediate {
Linger::Immediate
} else {
linger()
};
let (torn_down, restore) = {
let mut es = r.entries.lock().unwrap();
let Some(idx) = es.iter().position(|e| e.gen == gen) else {
return; // stale lease (entry reused + re-stamped, or already gone) — no-op
};
match es[idx].life.release(Instant::now(), linger) {
Release::Teardown | Release::Noop => {
let mut e = es.remove(idx);
let backend = e.backend;
// Per-group restore (§6.1): hand the physical re-enable to a surviving sibling, or run
// it now if this was the group's last member.
let restore = hand_off_restore(&mut es, backend, e.topology_restore.take());
(Some(e), restore)
}
Release::Linger => {
tracing::info!(
backend = es[idx].backend,
"virtual display: last session left — lingering (keep-alive)"
);
(None, None)
}
Release::Pin => {
tracing::info!(
backend = es[idx].backend,
"virtual display: last session left — pinned (keep-alive forever)"
);
(None, None)
}
// Linux entries are single-session (refs == 1), so Decref never occurs; harmless.
Release::Decref => (None, None),
}
};
// Re-enable the physicals (group emptied) BEFORE dropping the output — outside the lock.
if let Some(restore) = restore {
restore();
}
if let Some(e) = torn_down {
if force_immediate {
tracing::info!(
backend = e.backend,
"virtual display torn down (deliberate quit — keep-alive skipped)"
);
} else {
tracing::info!(
backend = e.backend,
"virtual display torn down (keep-alive off / released)"
);
}
drop(e); // outside the lock — the keepalive Drop may block
}
}
/// One live/kept display, flattened out of the pool under the lock — so the group + arrangement
/// math (which calls the layout engine) runs OUTSIDE the lock.
struct Row {
gen: u64,
backend: &'static str,
mode: Mode,
identity_slot: Option<u32>,
state: &'static str,
expires_in_ms: Option<u64>,
sessions: u32,
}
pub(super) fn snapshot() -> Vec<DisplayInfo> {
let Some(r) = REG.get() else {
return Vec::new();
};
let now = Instant::now();
// Flatten the live/kept entries under the lock (skip Idle — never stored anyway).
let rows: Vec<Row> = {
let es = r.entries.lock().unwrap();
es.iter()
.filter_map(|e| {
let (state, expires_in_ms, sessions) = match e.life {
lifecycle::State::Active { refs } => ("active", None, refs),
lifecycle::State::Lingering { until } => (
"lingering",
Some(until.saturating_duration_since(now).as_millis() as u64),
0,
),
lifecycle::State::Pinned => ("pinned", None, 0),
lifecycle::State::Idle => return None,
};
Some(Row {
gen: e.gen,
backend: e.backend,
mode: e.mode,
identity_slot: e.identity_slot,
state,
expires_in_ms,
sessions,
})
})
.collect()
};
let topology = super::topology_str();
// The arrangement policy: the console's manual layout when configured, else auto-row.
let layout_policy: Layout = policy::prefs()
.configured_effective()
.map(|e| e.layout)
.unwrap_or_default();
assemble_displays(rows, &layout_policy, &topology)
}
/// The desktop position for a display just appended to its group (design §6.2): the group's
/// `existing` members (each with its acquire `gen`) plus `new` last, ordered by `gen`, arranged by
/// the pure [`layout`] engine, taking the new member's placement. Pure — so the append-in-acquire-
/// order + auto-row/manual arrangement is unit-tested independent of the pool/global.
fn position_for_new(
mut existing: Vec<(u64, crate::vdisplay::layout::Member)>,
new: crate::vdisplay::layout::Member,
layout_policy: &Layout,
) -> crate::vdisplay::layout::Placement {
existing.sort_by_key(|(g, _)| *g);
let mut members: Vec<crate::vdisplay::layout::Member> =
existing.into_iter().map(|(_, m)| m).collect();
members.push(new);
*crate::vdisplay::layout::arrange(&members, layout_policy)
.last()
.expect("members is non-empty (just pushed `new`)")
}
/// The display **group** a backend+display belongs to (design §6.1). The desktop compositors
/// (KWin/Mutter/wlroots) put every managed output on ONE desktop → one group per backend. A
/// gamescope **spawn** is an independent nested session per client (no shared desktop), so each
/// gamescope display is its OWN group — never auto-rowed against, or topology-/restore-grouped with,
/// another gamescope session.
fn group_key(backend: &str, gen: u64) -> String {
if backend == "gamescope" {
format!("gamescope#{gen}")
} else {
backend.to_string()
}
}
/// Group the flattened rows into the mgmt `/display/state` view (design §6.1/§6.2) by
/// [`group_key`], ordered by acquire (`gen`), with each member's position from the pure [`layout`]
/// engine. Pure — no I/O, no global — so the grouping / ordering / position assignment is
/// unit-tested against synthetic rows.
fn assemble_displays(
rows: Vec<Row>,
layout_policy: &Layout,
topology: &str,
) -> Vec<DisplayInfo> {
use crate::vdisplay::layout::{self, Member};
// Small stable group ids by sorted group key — deterministic; in practice a host runs one live
// desktop backend → group 1 (with each gamescope spawn its own group).
let mut keys: Vec<String> = rows.iter().map(|r| group_key(r.backend, r.gen)).collect();
keys.sort();
keys.dedup();
let mut out: Vec<DisplayInfo> = Vec::new();
for (gi, key) in keys.iter().enumerate() {
// This group's members in acquire order (gen ascending) → display_index + arrangement.
let mut idx: Vec<usize> = rows
.iter()
.enumerate()
.filter(|(_, row)| &group_key(row.backend, row.gen) == key)
.map(|(i, _)| i)
.collect();
idx.sort_by_key(|&i| rows[i].gen);
let members: Vec<Member> = idx
.iter()
.map(|&i| Member {
identity_slot: rows[i].identity_slot,
width: rows[i].mode.width as i32,
})
.collect();
let places = layout::arrange(&members, layout_policy);
for (ord, &i) in idx.iter().enumerate() {
let row = &rows[i];
let p = places[ord];
out.push(DisplayInfo {
slot: row.gen,
backend: row.backend.to_string(),
mode: (row.mode.width, row.mode.height, row.mode.refresh_hz),
state: row.state.to_string(),
expires_in_ms: row.expires_in_ms,
sessions: row.sessions,
client: None,
group: gi as u32 + 1,
display_index: ord as u32,
position: (p.x, p.y),
identity_slot: row.identity_slot,
topology: topology.to_string(),
});
}
}
out
}
pub(super) fn force_release(slot: Option<u64>) -> usize {
let Some(r) = REG.get() else { return 0 };
let (released, restores) = {
let mut es = r.entries.lock().unwrap();
let mut out = Vec::new();
let mut restores = Vec::new();
let mut i = 0;
while i < es.len() {
let selected = slot.is_none_or(|s| es[i].gen == s);
if selected && es[i].life.force_release() {
let mut e = es.remove(i);
let backend = e.backend;
let restore = e.topology_restore.take();
if let Some(rst) = hand_off_restore(&mut es, backend, restore) {
restores.push(rst);
}
out.push(e);
} else {
i += 1;
}
}
(out, restores)
};
let n = released.len();
// Re-enable physicals (group emptied) BEFORE dropping the outputs — outside the lock.
for restore in restores {
restore();
}
for e in released {
tracing::info!(
backend = e.backend,
"virtual display released (mgmt /display/release)"
);
drop(e);
}
n
}
/// The session's refcount handle — the `keepalive` the capturer holds. `Drop` releases the
/// registry hold; a stale lease (its entry was reused + re-stamped, or torn down) is a no-op.
struct DisplayLease {
gen: u64,
/// The session's deliberate-quit flag: set when the client closes with the quit application
/// code (a user "stop", not a network drop), so this lease's `Drop` tears the display down
/// immediately instead of lingering. `false` on a bare disconnect → normal keep-alive.
quit: Arc<AtomicBool>,
}
impl Drop for DisplayLease {
fn drop(&mut self) {
release(self.gen, self.quit.load(Ordering::SeqCst));
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vdisplay::policy::{Layout, LayoutMode, Position};
use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
/// A minimal pool entry for the pure teardown/restore tests (dummy keepalive; the
/// `hand_off_restore` logic only reads `backend` + `topology_restore`).
fn test_entry(backend: &'static str, gen: u64, restore: Option<Restore>) -> Entry {
Entry {
life: lifecycle::State::default(),
keepalive: Box::new(()),
node_id: 0,
preferred_mode: None,
mode: Mode {
width: 1920,
height: 1080,
refresh_hz: 60,
},
backend,
identity_slot: None,
topology_restore: restore,
gen,
}
}
/// A restore closure that flips `flag` when run — so a test can assert exactly WHEN it fires.
fn flag_restore(flag: &Arc<AtomicBool>) -> Restore {
let f = flag.clone();
Box::new(move || f.store(true, Ordering::SeqCst))
}
#[test]
fn topology_restore_floats_to_a_sibling_then_runs_on_the_last_teardown() {
let ran = Arc::new(AtomicBool::new(false));
// Two KWin displays in one group; the first (gen 1) carries the group's restore.
let mut pool = vec![
test_entry("kwin", 1, Some(flag_restore(&ran))),
test_entry("kwin", 2, None),
];
// Tear down the restore-carrier while its sibling is still alive → transfer, don't run.
let mut e1 = pool.remove(0);
let out = hand_off_restore(&mut pool, "kwin", e1.topology_restore.take());
assert!(out.is_none(), "transferred, not run");
assert!(!ran.load(Ordering::SeqCst));
// The restore floated onto the surviving sibling.
assert!(pool[0].topology_restore.is_some());
// Tear down the last member → group empty → the restore is returned to run.
let mut e2 = pool.remove(0);
let out = hand_off_restore(&mut pool, "kwin", e2.topology_restore.take());
let action = out.expect("group empty → run the restore");
assert!(!ran.load(Ordering::SeqCst), "not run yet");
action();
assert!(ran.load(Ordering::SeqCst), "runs on the last drop");
}
#[test]
fn single_session_topology_restore_runs_on_its_own_teardown() {
// The validated single-display case: one exclusive session → restore runs at its teardown.
let ran = Arc::new(AtomicBool::new(false));
let mut pool = vec![test_entry("kwin", 1, Some(flag_restore(&ran)))];
let mut e = pool.remove(0);
let action = hand_off_restore(&mut pool, "kwin", e.topology_restore.take())
.expect("last (only) member → run");
action();
assert!(ran.load(Ordering::SeqCst));
}
#[test]
fn tearing_down_a_non_carrier_first_leaves_the_restore_for_last() {
let ran = Arc::new(AtomicBool::new(false));
// gen 2 carries the restore; gen 1 does not (a later exclusive session found the physical
// already disabled).
let mut pool = vec![
test_entry("kwin", 1, None),
test_entry("kwin", 2, Some(flag_restore(&ran))),
];
// Tear down the non-carrier first → nothing to hand off, carrier untouched.
let mut e1 = pool.remove(0);
assert!(hand_off_restore(&mut pool, "kwin", e1.topology_restore.take()).is_none());
// The carrier (gen 2) still holds the group's restore.
assert!(pool[0].topology_restore.is_some());
// Now the carrier (last member) → run.
let mut e2 = pool.remove(0);
hand_off_restore(&mut pool, "kwin", e2.topology_restore.take())
.expect("last member → run")();
assert!(ran.load(Ordering::SeqCst));
}
#[test]
fn restore_never_floats_across_backends() {
// group = backend: a KWin restore must not land on a Mutter display (a different desktop).
let ran = Arc::new(AtomicBool::new(false));
let mut pool = vec![test_entry("mutter", 2, None)];
let out = hand_off_restore(&mut pool, "kwin", Some(flag_restore(&ran)));
assert!(out.is_some(), "no same-backend sibling → return to run");
assert!(
pool[0].topology_restore.is_none(),
"restore must not cross into another backend's group"
);
}
fn row(gen: u64, backend: &'static str, w: u32, slot: Option<u32>) -> Row {
Row {
gen,
backend,
mode: Mode {
width: w,
height: 1080,
refresh_hz: 60,
},
identity_slot: slot,
state: "active",
expires_in_ms: None,
sessions: 1,
}
}
#[test]
fn groups_by_backend_and_auto_rows_in_acquire_order() {
// Two KWin displays (acquired gen 5 then gen 2 — deliberately out of vec order) + a Mutter one.
let rows = vec![
row(5, "kwin", 2560, Some(1)),
row(2, "kwin", 1920, Some(7)),
row(9, "mutter", 3840, None),
];
let out = assemble_displays(rows, &Layout::default(), "exclusive");
let kwin: Vec<&DisplayInfo> = out.iter().filter(|d| d.backend == "kwin").collect();
assert_eq!(kwin.len(), 2);
assert_eq!(kwin[0].slot, 2); // lower gen (earlier acquire) sorts to index 0
assert_eq!(kwin[0].display_index, 0);
assert_eq!(kwin[0].position, (0, 0));
assert_eq!(kwin[1].slot, 5);
assert_eq!(kwin[1].display_index, 1);
assert_eq!(kwin[1].position, (1920, 0)); // auto-row: after the 1920px gen-2 display
assert_eq!(kwin[0].topology, "exclusive");
// A distinct backend is a distinct group.
let mutter = out.iter().find(|d| d.backend == "mutter").unwrap();
assert_ne!(mutter.group, kwin[0].group);
assert_eq!(mutter.display_index, 0);
assert_eq!(mutter.position, (0, 0));
}
#[test]
fn position_for_new_appends_right_in_acquire_order() {
use crate::vdisplay::layout::{Member, Placement};
let m = |slot, w| Member {
identity_slot: slot,
width: w,
};
// Existing group (given out of gen order): gen 8 @ 1920 acquired AFTER gen 3 @ 2560.
let existing = vec![(8, m(Some(2), 1920)), (3, m(Some(1), 2560))];
// A new 1280-wide display appends to the right of 2560 + 1920.
let pos = position_for_new(existing, m(Some(5), 1280), &Layout::default());
assert_eq!(pos, Placement { x: 4480, y: 0 });
// First-of-group lands at the origin (so the registry skips the apply).
let first = position_for_new(vec![], m(None, 3840), &Layout::default());
assert_eq!(first, Placement { x: 0, y: 0 });
}
#[test]
fn position_for_new_honors_a_manual_pin() {
use crate::vdisplay::layout::{Member, Placement};
let mut positions = BTreeMap::new();
positions.insert("5".to_string(), Position { x: 100, y: 200 });
let layout = Layout {
mode: LayoutMode::Manual,
positions,
};
let new = Member {
identity_slot: Some(5),
width: 1280,
};
let pos = position_for_new(vec![(1, new)], new, &layout);
assert_eq!(pos, Placement { x: 100, y: 200 });
}
#[test]
fn gamescope_spawns_are_separate_groups() {
// Two independent gamescope spawns must NOT share a group or auto-row against each other.
let rows = vec![
row(1, "gamescope", 1920, None),
row(2, "gamescope", 1280, None),
];
let out = assemble_displays(rows, &Layout::default(), "extend");
assert_eq!(out.len(), 2);
assert_ne!(out[0].group, out[1].group, "distinct groups");
// Each is display 0 of its own group, at the origin (not auto-rowed against the other).
assert_eq!(out[0].display_index, 0);
assert_eq!(out[1].display_index, 0);
assert_eq!(out[0].position, (0, 0));
assert_eq!(out[1].position, (0, 0));
}
#[test]
fn manual_layout_keys_positions_by_identity_slot() {
// Client 7 arranged to the LEFT of client 1 (reversed vs. auto-row).
let rows = vec![row(1, "kwin", 2560, Some(1)), row(2, "kwin", 1920, Some(7))];
let mut positions = BTreeMap::new();
positions.insert("1".to_string(), Position { x: 1920, y: 0 });
positions.insert("7".to_string(), Position { x: 0, y: 0 });
let layout = Layout {
mode: LayoutMode::Manual,
positions,
};
let out = assemble_displays(rows, &layout, "extend");
let by_slot = |s: u32| out.iter().find(|d| d.identity_slot == Some(s)).unwrap();
assert_eq!(by_slot(1).position, (1920, 0));
assert_eq!(by_slot(7).position, (0, 0));
}
}
}
@@ -1,172 +0,0 @@
//! Per-client → stable monitor-id map for pf-vdisplay (Phase 2: per-client display-config persistence).
//!
//! Windows keys per-monitor config — notably DPI **scaling** (`HKCU\Control Panel\Desktop\PerMonitorSettings`)
//! — on the monitor's EDID identity AND its OS device path (whose per-connector discriminator is the IddCx
//! `ConnectorIndex` → target UID). The pf-vdisplay driver seeds BOTH the EDID serial and the `ConnectorIndex`
//! from a single monitor `id`. So for Windows to REAPPLY a given client's saved scaling on reconnect, that
//! client must get the SAME `id` every time. This map assigns each client (keyed by its cert fingerprint) a
//! STABLE id and the host passes it as [`AddRequest::preferred_monitor_id`](pf_driver_proto::control::AddRequest).
//!
//! The id space is bounded to `1..=15` because the driver uses the id as the IddCx `ConnectorIndex`, which
//! must stay `< MaxMonitorsSupported` (16). When more than 15 distinct clients are remembered, the
//! LEAST-RECENTLY-USED entry is evicted and its id reused (that evicted client simply re-establishes its
//! scaling once on its next connect). The map persists to `%ProgramData%\punktfunk\pf-vdisplay-identity.json`
//! so ids — and therefore the client→config association — survive host restarts.
//!
//! Anonymous/TOFU and GameStream sessions have no fingerprint and resolve to id `0` (auto) upstream, never
//! reaching this map — they keep the driver's lowest-free slot behavior unchanged.
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
/// Max stable id. The driver uses the id as the IddCx `ConnectorIndex`, which must stay
/// `< MaxMonitorsSupported` (16) — so ids run `1..=15`.
const MAX_ID: u32 = 15;
#[derive(Serialize, Deserialize, Default)]
struct Store {
/// Monotonic most-recently-used counter (the entry with the highest `seen` is the MRU). Persisted so
/// the LRU ordering survives host restarts.
tick: u64,
entries: Vec<Entry>,
}
#[derive(Serialize, Deserialize)]
struct Entry {
/// Lower-hex client cert fingerprint (the map key).
fp: String,
/// The client's stable monitor id (`1..=15`).
id: u32,
/// MRU stamp (compared against [`Store::tick`]).
seen: u64,
}
/// Persistent fingerprint → stable-id map (see the module docs).
pub(crate) struct MonitorIdentityMap {
path: PathBuf,
store: Store,
}
impl MonitorIdentityMap {
/// Load the persisted map (empty on first run / unreadable / parse failure — a fresh map just
/// re-derives ids, costing a client one scaling re-set the first time).
pub(crate) fn load() -> Self {
let path = crate::gamestream::config_dir().join("pf-vdisplay-identity.json");
let mut store = std::fs::read(&path)
.ok()
.and_then(|b| serde_json::from_slice::<Store>(&b).ok())
.unwrap_or_default();
// SANITIZE a hand-edited / corrupt / cross-version file before trusting it: resolve()'s found-entry
// branch returns the stored id verbatim, so an out-of-range id (0 = the "auto" sentinel, or
// > MAX_ID) or a duplicate id/fp would flow straight into preferred_monitor_id. Drop out-of-range
// ids and dedup by BOTH fp and id (keeping the most-recently-seen on a clash) so no two fingerprints
// can map to the same id. (The driver also rejects a live-colliding id as a backstop.)
store.entries.sort_by_key(|e| std::cmp::Reverse(e.seen));
let mut seen_fp = std::collections::HashSet::new();
let mut seen_id = std::collections::HashSet::new();
store.entries.retain(|e| {
(1..=MAX_ID).contains(&e.id) && seen_fp.insert(e.fp.clone()) && seen_id.insert(e.id)
});
Self { path, store }
}
/// The stable id (`1..=15`) for the client fingerprint `fp`: its remembered id, or a freshly assigned
/// one (lowest free, else LRU-evict at the cap). Bumps the entry to MRU and persists.
pub(crate) fn resolve(&mut self, fp: [u8; 32]) -> u32 {
let key: String = fp.iter().map(|b| format!("{b:02x}")).collect();
self.store.tick = self.store.tick.wrapping_add(1);
let now = self.store.tick;
if let Some(e) = self.store.entries.iter_mut().find(|e| e.fp == key) {
e.seen = now;
let id = e.id;
self.persist();
return id;
}
// New client: prefer the lowest free id in 1..=MAX_ID; if all are taken, evict the LRU entry and
// reuse its id (the evicted client re-establishes its scaling once on its next connect).
let id = (1..=MAX_ID)
.find(|i| !self.store.entries.iter().any(|e| e.id == *i))
.unwrap_or_else(|| {
let lru = self
.store
.entries
.iter()
.enumerate()
.min_by_key(|(_, e)| e.seen)
.map(|(i, _)| i)
.expect("entries are non-empty whenever every id 1..=MAX_ID is taken");
let evicted = self.store.entries.remove(lru);
evicted.id
});
self.store.entries.push(Entry {
fp: key,
id,
seen: now,
});
self.persist();
id
}
/// Persist atomically (temp file + rename). Best-effort: a write failure just means a restart may
/// re-derive an id (one scaling re-set). Not a credential, so a plain (non-ACL'd) write is fine.
fn persist(&self) {
let Ok(bytes) = serde_json::to_vec_pretty(&self.store) else {
return;
};
if let Some(dir) = self.path.parent() {
let _ = std::fs::create_dir_all(dir);
}
let tmp = self.path.with_extension("json.tmp");
if std::fs::write(&tmp, &bytes).is_ok() {
let _ = std::fs::rename(&tmp, &self.path);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(n: u8) -> [u8; 32] {
let mut f = [0u8; 32];
f[0] = n;
f
}
#[test]
fn stable_across_calls_and_distinct_per_client() {
let mut m = MonitorIdentityMap {
path: std::env::temp_dir().join(format!("pf-id-test-{}.json", std::process::id())),
store: Store::default(),
};
let a1 = m.resolve(fp(1));
let b = m.resolve(fp(2));
let a2 = m.resolve(fp(1));
assert_eq!(a1, a2, "same client → same id");
assert_ne!(a1, b, "distinct clients → distinct ids");
assert!((1..=MAX_ID).contains(&a1) && (1..=MAX_ID).contains(&b));
let _ = std::fs::remove_file(&m.path);
}
#[test]
fn lru_eviction_reuses_an_id_at_the_cap() {
let mut m = MonitorIdentityMap {
path: std::env::temp_dir().join(format!("pf-id-lru-{}.json", std::process::id())),
store: Store::default(),
};
// Fill all 15 ids (clients 1..=15), then touch client 2 so client 1 is the LRU.
for n in 1..=15u8 {
m.resolve(fp(n));
}
let _ = m.resolve(fp(2));
// A 16th client evicts the LRU (client 1) and reuses its id; ids stay bounded.
let id16 = m.resolve(fp(16));
assert!((1..=MAX_ID).contains(&id16));
assert_eq!(m.store.entries.len(), 15, "cap holds at 15 entries");
assert!(m.store.entries.iter().all(|e| (1..=MAX_ID).contains(&e.id)));
let _ = std::fs::remove_file(&m.path);
}
}
@@ -34,7 +34,7 @@ use windows::Win32::System::Threading::{
use super::{Mode, VirtualOutput};
use crate::win_display::{
force_extend_topology, isolate_displays_ccd, resolve_gdi_name, restore_displays_ccd,
set_active_mode, SavedConfig,
set_active_mode, set_virtual_primary_ccd, SavedConfig,
};
/// The per-backend REMOVE key the driver stamps on ADD and consumes on REMOVE. SudoVDA keys monitors by
@@ -129,8 +129,22 @@ impl Monitor {
enum MgrState {
Idle,
Active { mon: Monitor, refs: u32 },
Lingering { mon: Monitor, until: Instant },
Active {
mon: Monitor,
refs: u32,
},
Lingering {
mon: Monitor,
until: Instant,
},
/// `keep_alive = forever` (gaming-rig): the monitor is kept indefinitely after the last session
/// leaves — like `Lingering` but the linger timer never tears it down. A reconnect preempts +
/// recreates it (same as `Lingering`, since a reused IddCx swap-chain is dead); only the mgmt
/// `/display/release` (or host shutdown) frees it. The physical screens stay off (exclusive) for
/// the box's life — the §8 release-now escape hatch (`force_release`) is the way back.
Pinned {
mon: Monitor,
},
}
/// The manager's control-device cache. Reopenable: a driver upgrade / WUDFHost restart kills the
@@ -169,10 +183,10 @@ pub(crate) struct VirtualDisplayManager {
/// The current IDD-push session's stop flag; a new connection signals the prior one to release its
/// monitor before the fresh one is created (was the `IDD_SESSION_STOP` global in `punktfunk1`).
idd_session_stop: Mutex<Option<Arc<AtomicBool>>>,
/// Persistent per-client (cert-fingerprint) → stable monitor-id map. A monitor CREATE resolves the
/// connecting client's id here, so the client keeps the same EDID serial + IddCx ConnectorIndex across
/// reconnects and Windows reapplies its saved per-monitor config (DPI scaling). See [`super::identity`].
identity_map: Mutex<super::identity::MonitorIdentityMap>,
// The per-client stable monitor-id map is now the process-wide `super::identity::global()`
// (shared with the Linux KWin backend's per-slot naming — never same-process). A monitor CREATE
// resolves the client's id via `identity::resolve_slot`, so it keeps the same EDID serial + IddCx
// ConnectorIndex across reconnects and Windows reapplies its saved per-monitor DPI scaling.
}
static VDM: OnceLock<VirtualDisplayManager> = OnceLock::new();
@@ -188,7 +202,6 @@ pub(crate) fn init(driver: Box<dyn VdisplayDriver>) -> &'static VirtualDisplayMa
state: Mutex::new(MgrState::Idle),
setup_lock: Mutex::new(()),
idd_session_stop: Mutex::new(None),
identity_map: Mutex::new(super::identity::MonitorIdentityMap::load()),
})
}
@@ -387,22 +400,28 @@ impl VirtualDisplayManager {
let mut state = self.state.lock().unwrap();
let dev = self.ensure_device()?;
// IDD-push: a new connection while a monitor is LINGERING is a single-client RECONNECT (the
// prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it hands a black
// screen — PREEMPT: tear the lingering monitor down (its key/topology are restored) and create a
// fresh one. The old session's lease is gen-stamped, so its later drop is a no-op.
// IDD-push: a new connection while a monitor is kept (LINGERING or PINNED) is a single-client
// RECONNECT (the prior session fully released). A REUSED IddCx swap-chain is DEAD, so reusing it
// hands a black screen — PREEMPT: tear the kept monitor down (its key/topology are restored) and
// create a fresh one. The old session's lease is gen-stamped, so its later drop is a no-op.
//
// ONLY Lingering, NOT Active: an Active monitor still has a lease held — that's the build-retry
// path (`build_pipeline_with_retry` holds one lease across all attempts) or a concurrent session,
// NOT a reconnect. Preempting Active would tear a live session down AND churn REMOVE→ADD on every
// retry — the per-cold-start monitor churn that exhausts the IddCx slot pool and wedges ADD at
// 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD).
if matches!(*state, MgrState::Lingering { .. }) {
if let MgrState::Lingering { mon, .. } = std::mem::replace(&mut *state, MgrState::Idle)
{
// ONLY the kept states, NOT Active: an Active monitor still has a lease held — that's the
// build-retry path (`build_pipeline_with_retry` holds one lease across all attempts) or a
// concurrent session, NOT a reconnect. Preempting Active would tear a live session down AND churn
// REMOVE→ADD on every retry — the per-cold-start monitor churn that exhausts the IddCx slot pool
// and wedges ADD at 0x80070490. Active falls through to the JOIN path below (refcount++, no ADD).
if matches!(*state, MgrState::Lingering { .. } | MgrState::Pinned { .. }) {
let taken = match std::mem::replace(&mut *state, MgrState::Idle) {
MgrState::Lingering { mon, .. } | MgrState::Pinned { mon } => Some(mon),
other => {
*state = other;
None
}
};
if let Some(mon) = taken {
tracing::info!(
old_target = mon.target_id,
"IDD-push reconnect — preempting the lingering monitor, recreating a fresh one"
"IDD-push reconnect — preempting the kept (lingering/pinned) monitor, recreating a fresh one"
);
// SAFETY: `teardown` requires `dev` to be a valid control handle; `dev` is the value
// `ensure_device()` returned above (cached handles are never closed — a dead one is
@@ -458,12 +477,14 @@ impl VirtualDisplayManager {
return Ok(self.output_for(mon));
}
// Idle or Lingering: repurpose a lingering monitor / create a fresh one → Active{refs:1}.
// Idle or kept: repurpose a kept monitor / create a fresh one → Active{refs:1}. (In practice a
// kept Lingering/Pinned monitor was already preempted → Idle above; this arm is the defensive
// reuse path if a race left one here — it must stay exhaustive over `Pinned` regardless.)
let mon = match std::mem::replace(&mut *state, MgrState::Idle) {
MgrState::Lingering { mut mon, .. } => {
MgrState::Lingering { mut mon, .. } | MgrState::Pinned { mut mon } => {
tracing::info!(
backend = self.driver.name(),
"virtual monitor reused (reconnect within the linger window)"
"virtual monitor reused (reconnect to a kept monitor)"
);
if mon.mode != mode {
// SAFETY: `reconfigure` needs an exclusive `&mut Monitor` and only touches the live
@@ -527,10 +548,14 @@ impl VirtualDisplayManager {
) -> Result<Monitor> {
// Resolve the connecting client's STABLE per-client monitor id (so Windows reapplies its saved
// per-monitor config — DPI scaling — on reconnect); `None`/anonymous → 0 = the driver
// auto-allocates the lowest-free id (the original slot-based behavior).
let preferred_id = client_fp
.map(|fp| self.identity_map.lock().unwrap().resolve(fp))
.unwrap_or(0);
// auto-allocates the lowest-free id (the original slot-based behavior). The `identity` policy
// picks per-client vs per-client-mode; Windows defaults to PerClient (its historical behavior).
let preferred_id = super::identity::resolve_slot(
client_fp,
(mode.width, mode.height),
crate::vdisplay::policy::Identity::PerClient,
)
.unwrap_or(0);
// SAFETY: `create_monitor`'s own `# Safety` contract guarantees `dev` is the live control
// handle; we forward it unchanged to `add_monitor`, whose precondition is exactly that.
// `resolve_render_pin()` returns an `Option<LUID>` by value (plain `Copy`), so no borrowed
@@ -630,17 +655,40 @@ impl VirtualDisplayManager {
tracing::info!(backend = self.driver.name(), "target {} -> {n}", added.target_id);
// ADD only advertises the mode; force it active so DXGI captures the requested size.
set_active_mode(n, mode);
// Make the virtual display the SOLE active output (default): an EXTENDED (non-primary) IDD
// isn't DWM-composited on this box → Desktop Duplication born-losts. Deactivating the other
// display(s) first via the atomic CCD path promotes the IDD to a composited primary with no
// MODE_CHANGE storm. Opt out with PUNKTFUNK_NO_ISOLATE=1.
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_err() {
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes a
// `Copy` `u32` by value and returns an owned `SavedConfig` snapshot (no borrowed
// memory crosses). It runs under the `state` lock, the sole mutator of the topology.
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
} else {
tracing::info!("display isolation skipped (PUNKTFUNK_NO_ISOLATE) — IDD stays extended");
// Apply the display-management topology (Stage 2). `Exclusive` (default) deactivates the
// other display(s) so the IDD is the SOLE composited primary — an EXTENDED (non-primary)
// IDD isn't DWM-composited on this box → Desktop Duplication born-losts. `Primary` keeps the
// physical display(s) ACTIVE and makes the IDD primary (repositioned to origin). `Extend`
// leaves it a plain extension. Both isolate + primary go through the atomic CCD path (no
// MODE_CHANGE storm). Opt out (extend) with PUNKTFUNK_NO_ISOLATE=1 / the console policy.
use crate::vdisplay::policy::Topology;
match topology_action() {
Topology::Exclusive => {
// SAFETY: `isolate_displays_ccd` is `unsafe` for its CCD topology FFI; it takes the
// `Copy` target id by value and returns an owned `SavedConfig` (no borrowed memory
// crosses), under the `state` lock — the sole topology mutator.
ccd_saved = unsafe { isolate_displays_ccd(added.target_id) };
}
Topology::Primary => {
// The IDD auto-activates as the SOLE display on a headless box, so the
// physical (if present) is deactivated and QueryDisplayConfig sees only the
// virtual. Force EXTEND first to (re)activate every CONNECTED display
// alongside the virtual, THEN reposition to make the virtual primary — so the
// physical stays active. (The bring-up above only force-EXTENDs when the
// virtual FAILS to auto-resolve; here it resolved, so we do it explicitly.)
// SAFETY: `force_extend_topology` drives the CCD topology FFI (no args, no borrowed
// memory), under the `state` lock — the sole topology mutator.
unsafe { force_extend_topology() };
thread::sleep(Duration::from_millis(300));
// SAFETY: `set_virtual_primary_ccd` takes the `Copy` target id by value and returns
// an owned `SavedConfig` (no borrowed memory crosses), under the `state` lock.
ccd_saved = unsafe { set_virtual_primary_ccd(added.target_id) };
}
Topology::Extend | Topology::Auto => {
tracing::info!(
"display topology=extend — IDD stays extended (no isolate / no primary)"
);
}
}
thread::sleep(Duration::from_millis(1500)); // let the topology settle before capture opens
}
@@ -725,7 +773,9 @@ impl VirtualDisplayManager {
fn release(&self, gen: u64) {
let mut state = self.state.lock().unwrap();
let stale = match &*state {
MgrState::Active { mon, .. } | MgrState::Lingering { mon, .. } => mon.gen != gen,
MgrState::Active { mon, .. }
| MgrState::Lingering { mon, .. }
| MgrState::Pinned { mon } => mon.gen != gen,
MgrState::Idle => true,
};
if stale {
@@ -736,6 +786,14 @@ impl VirtualDisplayManager {
mon,
refs: refs - 1,
},
// Last session left: keep the monitor forever (Pinned) under `keep_alive = forever`,
// else linger for the policy window before the timer tears it down.
MgrState::Active { mon, .. } if keep_alive_forever() => {
tracing::info!(
"virtual-display: last session left — PINNED (keep_alive=forever); free via /display/release"
);
MgrState::Pinned { mon }
}
MgrState::Active { mon, .. } => {
let ms = linger_ms();
tracing::info!(
@@ -890,10 +948,139 @@ fn resolve_render_pin() -> Option<LUID> {
crate::win_adapter::resolve_render_adapter_luid()
}
/// Linger window before a session-less monitor is torn down (default 10 s; `PUNKTFUNK_MONITOR_LINGER_MS`).
/// A read-only view of the managed monitor for the mgmt `/display/state` endpoint (Goal:
/// display-management registry facade). Backend-neutral; the [`crate::vdisplay::registry`] facade
/// maps it into the wire shape.
pub(crate) struct ManagedInfo {
pub backend: &'static str,
pub mode: (u32, u32, u32),
/// `"active"` | `"lingering"` | `"pinned"`.
pub state: &'static str,
/// Milliseconds until a lingering monitor is torn down (`None` when active).
pub expires_in_ms: Option<u64>,
/// Live sessions holding the monitor.
pub sessions: u32,
/// The monitor's generation stamp — a stable-enough id for the `/display/release` slot arg.
pub gen: u64,
}
impl VirtualDisplayManager {
/// Snapshot the current monitor for the mgmt `/display/state` endpoint. `None` when Idle.
pub(crate) fn snapshot(&self) -> Option<ManagedInfo> {
let st = self.state.lock().unwrap();
let (mon, state, sessions, expires_in_ms) = match &*st {
MgrState::Idle => return None,
MgrState::Active { mon, refs } => (mon, "active", *refs, None),
MgrState::Lingering { mon, until } => {
let ms = until.saturating_duration_since(Instant::now()).as_millis() as u64;
(mon, "lingering", 0u32, Some(ms))
}
// Pinned (keep_alive=forever): kept indefinitely, no expiry — the console shows "Pinned".
MgrState::Pinned { mon } => (mon, "pinned", 0u32, None),
};
Some(ManagedInfo {
backend: self.driver.name(),
mode: (mon.mode.width, mon.mode.height, mon.mode.refresh_hz),
state,
expires_in_ms,
sessions,
gen: mon.gen,
})
}
/// Force-tear-down a kept (LINGERING **or** PINNED) monitor now (the `/display/release` endpoint) —
/// so a physical-screen user gets their screen back without waiting out the linger, and it is the §8
/// escape hatch that frees a `keep_alive=forever` (Pinned) monitor. An Active monitor is refused
/// (stopping a live session is session management, not display management). Returns `true` if a kept
/// monitor was released.
pub(crate) fn force_release(&self) -> bool {
let Some(dev) = self.device_handle() else {
return false;
};
let mut st = self.state.lock().unwrap();
if matches!(&*st, MgrState::Lingering { .. } | MgrState::Pinned { .. }) {
let mon = match std::mem::replace(&mut *st, MgrState::Idle) {
MgrState::Lingering { mon, .. } | MgrState::Pinned { mon } => Some(mon),
other => {
*st = other;
None
}
};
if let Some(mon) = mon {
// SAFETY: `teardown` needs a live control handle; `dev` is from `device_handle()`
// (cached handles are never closed — a dead one is retired, kept alive; see
// `DeviceSlot`). `mon` was moved out of the kept state under the `state` lock,
// so it is exclusively owned here — no aliasing.
unsafe { self.teardown(dev, mon) };
return true;
}
}
false
}
}
/// Snapshot the managed monitor, or `None` when no backend has initialised the manager yet (no
/// session has ever run) or it is Idle. Safe to call per management request.
pub(crate) fn snapshot() -> Option<ManagedInfo> {
VDM.get().and_then(VirtualDisplayManager::snapshot)
}
/// Force-release a lingering monitor now; `false` if nothing was lingering (or the manager is
/// uninitialised).
pub(crate) fn force_release() -> bool {
VDM.get()
.map(VirtualDisplayManager::force_release)
.unwrap_or(false)
}
/// Linger window before a session-less monitor is torn down. The console display-management policy
/// wins when configured (`keep_alive`); otherwise the legacy `PUNKTFUNK_MONITOR_LINGER_MS` env knob,
/// else the 10 s default.
fn linger_ms() -> u64 {
use crate::vdisplay::policy::{prefs, Linger};
if let Some(eff) = prefs().configured_effective() {
return match eff.keep_alive.linger() {
Linger::Immediate => 0,
Linger::For(d) => d.as_millis() as u64,
// `forever` is handled BEFORE this by `keep_alive_forever()` in `release` (→ `Pinned`), so
// this arm is only reached defensively (e.g. a caller that resolves ms without the pin
// check) — fall back to the default rather than a huge linger.
Linger::Forever => 10_000,
};
}
std::env::var("PUNKTFUNK_MONITOR_LINGER_MS")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(10_000)
}
/// Whether the configured console policy's `keep_alive` resolves to **forever** (`Pinned`) — the
/// gaming-rig preset. `release` uses this to keep the last-released monitor indefinitely instead of
/// lingering. Unconfigured hosts are never forever (default is a short linger).
fn keep_alive_forever() -> bool {
use crate::vdisplay::policy::{prefs, Linger};
prefs()
.configured_effective()
.map(|eff| matches!(eff.keep_alive.linger(), Linger::Forever))
.unwrap_or(false)
}
/// The effective display topology for a freshly-created monitor (never `Auto`): the console policy's
/// [`effective_topology`](crate::vdisplay::effective_topology) when configured, else the legacy
/// `PUNKTFUNK_NO_ISOLATE` env knob (`Extend`) / `Exclusive` (today's default). `Extend` leaves the IDD
/// extended; `Primary` makes it primary while keeping the physical(s) active; `Exclusive` disables the
/// physical(s) so the IDD is the sole composited desktop.
fn topology_action() -> crate::vdisplay::policy::Topology {
use crate::vdisplay::policy::Topology;
if crate::vdisplay::policy::prefs()
.configured_effective()
.is_some()
{
return crate::vdisplay::effective_topology();
}
if std::env::var("PUNKTFUNK_NO_ISOLATE").is_ok() {
Topology::Extend
} else {
Topology::Exclusive
}
}
+139 -29
View File
@@ -18,11 +18,13 @@ use windows::Win32::Devices::Display::{
DisplayConfigGetDeviceInfo, DisplayConfigSetDeviceInfo, GetDisplayConfigBufferSizes,
QueryDisplayConfig, SetDisplayConfig, DISPLAYCONFIG_DEVICE_INFO_GET_ADVANCED_COLOR_INFO,
DISPLAYCONFIG_DEVICE_INFO_GET_SOURCE_NAME, DISPLAYCONFIG_DEVICE_INFO_SET_ADVANCED_COLOR_STATE,
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO, DISPLAYCONFIG_PATH_INFO,
DISPLAYCONFIG_GET_ADVANCED_COLOR_INFO, DISPLAYCONFIG_MODE_INFO,
DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE, DISPLAYCONFIG_PATH_INFO,
DISPLAYCONFIG_SET_ADVANCED_COLOR_STATE, DISPLAYCONFIG_SOURCE_DEVICE_NAME,
QDC_ONLY_ACTIVE_PATHS, SDC_ALLOW_CHANGES, SDC_APPLY, SDC_FORCE_MODE_ENUMERATION,
SDC_SAVE_TO_DATABASE, SDC_TOPOLOGY_EXTEND, SDC_USE_SUPPLIED_DISPLAY_CONFIG,
};
use windows::Win32::Foundation::POINTL;
use windows::Win32::Graphics::Gdi::{
ChangeDisplaySettingsExW, EnumDisplaySettingsW, CDS_TEST, CDS_UPDATEREGISTRY, DEVMODEW,
DISP_CHANGE_SUCCESSFUL, DM_BITSPERPEL, DM_DISPLAYFREQUENCY, DM_PELSHEIGHT, DM_PELSWIDTH,
@@ -353,6 +355,48 @@ pub(crate) type SavedConfig = (Vec<DISPLAYCONFIG_PATH_INFO>, Vec<DISPLAYCONFIG_M
/// doesn't export it, so define it here.
const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
/// Query the current ACTIVE display config (paths + modes), truncated to the real counts. `None` on
/// API failure. Shared by [`isolate_displays_ccd`] (snapshot + per-attempt re-query) and
/// [`count_other_active`].
unsafe fn query_active_config() -> Option<SavedConfig> {
let mut np = 0u32;
let mut nm = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
return None;
}
let mut paths = vec![DISPLAYCONFIG_PATH_INFO::default(); np as usize];
let mut modes = vec![DISPLAYCONFIG_MODE_INFO::default(); nm as usize];
if QueryDisplayConfig(
QDC_ONLY_ACTIVE_PATHS,
&mut np,
paths.as_mut_ptr(),
&mut nm,
modes.as_mut_ptr(),
None,
)
.is_err()
{
return None;
}
paths.truncate(np as usize);
modes.truncate(nm as usize);
Some((paths, modes))
}
/// Count currently-ACTIVE display paths whose target id != `keep_target_id` — i.e. displays that would
/// still be lit besides the virtual one. `None` on query failure. Used to VERIFY isolation actually took.
unsafe fn count_other_active(keep_target_id: u32) -> Option<u32> {
let (paths, _) = query_active_config()?;
Some(
paths
.iter()
.filter(|p| {
p.targetInfo.id != keep_target_id && p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0
})
.count() as u32,
)
}
/// Robust display isolation via the CCD API. The naive GDI approach (EnumDisplayDevices +
/// ChangeDisplaySettings) MISSES displays on a hybrid box — an iGPU-attached physical monitor isn't
/// flagged `ATTACHED_TO_DESKTOP` in the GDI enum, so it's never detached and the secure desktop /
@@ -363,6 +407,61 @@ const DISPLAYCONFIG_PATH_ACTIVE: u32 = 0x0000_0001;
// pub(crate) so vdisplay::pf_vdisplay can reuse this backend-neutral CCD isolation helper
// (it operates on a real OS target id — a pf-vdisplay monitor's target_id qualifies).
pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedConfig> {
// Snapshot the ORIGINAL active config ONCE for restore-on-teardown, before any changes.
let saved = query_active_config()?;
// Deactivate every non-keep display, then VERIFY and RETRY. A field-reported bug had a physical
// monitor STAY ACTIVE in exclusive mode, so we don't trust a single SetDisplayConfig: re-query the
// live topology each attempt and re-apply until ONLY the keep target is active. Secure-desktop
// correctness depends on this — the lock screen must not land on a stray panel while we stream.
for attempt in 1..=4u32 {
let (mut paths, modes) = query_active_config()?;
let mut others = 0u32;
for p in paths.iter_mut() {
if p.targetInfo.id == keep_target_id {
continue;
}
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
others += 1;
}
}
// 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
// never calls ASSIGN_SWAPCHAIN, so the driver receives no frames. SDC_FORCE_MODE_ENUMERATION
// forces the re-commit; SAVE_TO_DATABASE only in the sole-path case (matches prior behavior —
// don't permanently rewrite the user's multi-display layout; the teardown restore handles it).
let mut flags = SDC_APPLY
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
| SDC_ALLOW_CHANGES
| SDC_FORCE_MODE_ENUMERATION;
if others == 0 {
flags |= SDC_SAVE_TO_DATABASE;
}
let rc = SetDisplayConfig(Some(paths.as_slice()), Some(modes.as_slice()), flags);
// VERIFY the OUTCOME (rc alone lies — a "successful" apply can leave a panel active): re-query
// and confirm no non-keep display survived. Only then is the virtual truly the sole desktop.
let survivors = count_other_active(keep_target_id).unwrap_or(0);
if survivors == 0 {
tracing::info!("display isolate (CCD): target {keep_target_id} is the SOLE active desktop (attempt {attempt}/4, deactivated {others}, rc={rc:#x})");
return Some(saved);
}
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));
}
tracing::error!("display isolate (CCD): FAILED to isolate target {keep_target_id} after 4 attempts — a non-virtual display stayed active (the field-reported exclusive-mode bug)");
Some(saved)
}
/// **Primary (topology=primary)** — make the virtual output the PRIMARY display while KEEPING every
/// other display ACTIVE (unlike [`isolate_displays_ccd`], which deactivates them). Windows treats the
/// display whose source sits at the desktop origin `(0,0)` as primary, so we move the virtual's source
/// to `(0,0)` and shift every other active source to its right — all paths stay active. Done as ONE
/// atomic CCD `SetDisplayConfig` (NOT GDI `CDS_SET_PRIMARY`, which storms
/// `DXGI_ERROR_MODE_CHANGE_IN_PROGRESS` when another display is live — see [`set_active_mode`]).
/// Returns the original config to restore on teardown.
pub(crate) unsafe fn set_virtual_primary_ccd(keep_target_id: u32) -> Option<SavedConfig> {
let mut np = 0u32;
let mut nm = 0u32;
if GetDisplayConfigBufferSizes(QDC_ONLY_ACTIVE_PATHS, &mut np, &mut nm).is_err() {
@@ -385,36 +484,47 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedCo
paths.truncate(np as usize);
modes.truncate(nm as usize);
let saved = (paths.clone(), modes.clone());
let mut others = 0u32;
for p in paths.iter_mut() {
if p.targetInfo.id == keep_target_id {
// The virtual output's source width, to lay the other displays out to its right.
let virt_width = paths.iter().find_map(|p| {
if p.targetInfo.id != keep_target_id {
return None;
}
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
let m = modes.get(idx)?;
// `then_some` (eager): `sourceMode.width` is a POD `u32` union read, discarded when the arm is
// false — no lazy guard needed. (`then(|| …)` here trips clippy::unnecessary_lazy_evaluations.)
(m.infoType == DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE)
.then_some(m.Anonymous.sourceMode.width as i32)
})?;
let others = paths.len().saturating_sub(1);
// Reposition each active path's SOURCE once: the virtual to (0,0) (= primary), the other
// displays PACKED left-to-right from the virtual's right edge — kept active, no overlap and no
// gap (vs. blindly shifting each by virt_width, which leaves a dead gap when EXTEND already
// placed them to the right). Dedup source-mode indices (a cloned group shares one).
let mut next_x = virt_width;
let mut done = std::collections::HashSet::new();
for p in paths.iter() {
let idx = p.sourceInfo.Anonymous.modeInfoIdx as usize;
if !done.insert(idx) {
continue;
}
if p.flags & DISPLAYCONFIG_PATH_ACTIVE != 0 {
p.flags &= !DISPLAYCONFIG_PATH_ACTIVE; // mark this path inactive
others += 1;
let Some(m) = modes.get_mut(idx) else {
continue;
};
if m.infoType != DISPLAYCONFIG_MODE_INFO_TYPE_SOURCE {
continue;
}
if p.targetInfo.id == keep_target_id {
m.Anonymous.sourceMode.position = POINTL { x: 0, y: 0 };
} else {
let w = m.Anonymous.sourceMode.width as i32;
m.Anonymous.sourceMode.position = POINTL { x: next_x, y: 0 };
next_x += w;
}
}
if others == 0 {
// The virtual path shows active in the CCD database (from set_active_mode's legacy
// ChangeDisplaySettingsExW), but a legacy mode-set does 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 never receives composed frames. Force an explicit CCD
// SetDisplayConfig commit of the (sole) virtual path so the IddCx path actually activates.
// SDC_FORCE_MODE_ENUMERATION makes the OS re-enumerate + re-commit even though the CCD DB
// already lists the path active.
let rc = SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
SDC_APPLY
| SDC_USE_SUPPLIED_DISPLAY_CONFIG
| SDC_ALLOW_CHANGES
| SDC_SAVE_TO_DATABASE
| SDC_FORCE_MODE_ENUMERATION,
);
tracing::info!("display isolate (CCD): forced CCD re-commit of sole virtual path {keep_target_id} rc={rc:#x} (drives IddCx COMMIT_MODES → ASSIGN_SWAPCHAIN)");
return Some(saved);
}
let rc = SetDisplayConfig(
Some(paths.as_slice()),
Some(modes.as_slice()),
@@ -424,9 +534,9 @@ pub(crate) unsafe fn isolate_displays_ccd(keep_target_id: u32) -> Option<SavedCo
| SDC_FORCE_MODE_ENUMERATION,
);
if rc == 0 {
tracing::info!("display isolate (CCD): deactivated {others} other display(s) — SudoVDA target {keep_target_id} is now the sole desktop");
tracing::info!("display primary (CCD): virtual target {keep_target_id} set PRIMARY at (0,0); {others} other display(s) kept ACTIVE + packed to its right");
} else {
tracing::warn!("display isolate (CCD): SetDisplayConfig failed rc={rc:#x} (tried to deactivate {others} path(s))");
tracing::warn!("display primary (CCD): SetDisplayConfig failed rc={rc:#x} (virtual {keep_target_id} primary, physicals kept)");
}
Some(saved)
}
+13
View File
@@ -33,6 +33,10 @@ pub struct Summary {
pub native_paired_clients: u32,
pub pin_pending: bool,
pub pending_approvals: u32,
/// Virtual displays kept with no live session (lingering/pinned). `#[serde(default)]` so an older
/// host that doesn't send it deserializes as 0.
#[serde(default)]
pub kept_displays: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize)]
@@ -71,6 +75,14 @@ impl TrayStatus {
s.version, sess.width, sess.height, sess.fps
),
(_, true) => format!("punktfunk host {} — streaming", s.version),
// Idle, but surface a kept (lingering/pinned) display: it — and, under an exclusive
// topology, your physical monitors — is being held. Release it from the console.
_ if s.kept_displays > 0 => format!(
"punktfunk host {} — idle · {} display{} kept",
s.version,
s.kept_displays,
if s.kept_displays == 1 { "" } else { "s" }
),
_ => format!("punktfunk host {} — idle", s.version),
},
}
@@ -432,6 +444,7 @@ mod tests {
native_paired_clients: 2,
pin_pending: false,
pending_approvals: 0,
kept_displays: 0,
}
}
+905
View File
@@ -0,0 +1,905 @@
# Virtual-display management & lifecycle policy — design
> **Status (2026-07-05):** **Stages 05 (§6A) DONE + on-glass validated; keep-alive reconnect
> hardened** (branch `display-mgmt-stage0`, not yet merged). Stage 5 §6A: display **groups**
> (`registry::group_key` — one per desktop backend, each gamescope spawn its own), group-aware
> `exclusive`/`primary` (KWin name-filter + first-slot-wins; Mutter `set_first_in_group`), **per-group
> topology restore** (KWin restore floats through the group, runs on the last member's teardown), the
> **layout engine** (`vdisplay/layout.rs`, auto-row + manual) + registry-driven `apply_position`, the
> `PUT /display/layout` endpoint with group/position/index in `/display/state`, and the **web console
> arrangement table** — **live-validated on KWin `.116` + Mutter `.21`** (group model, positions,
> identity keying, group-aware exclusive/extend, 2 concurrent Mutter `RecordVirtual` monitors). The
> Stage-3 **KDE scaling round-trip is now proven live** (set 150 %/125 % → disconnect → reconnect →
> reapplied, seen in `kwinoutputconfig.json`). **Keep-alive reconnect hardening (`b53710d`, on-glass
> validated with the probe):** a same-client reconnect **preempts its own zombie**
> (`admission::preempt_same_identity` — fixes "reconnect within the idle-detection window lands on a
> fresh SECOND display while the old one keeps streaming"), a **deliberate quit skips the linger**
> (client closes with `QUIT_CLOSE_CODE` 0x51 → `registry::release(force_immediate)`; §5.1), and the QUIC
> control-connection idle timeout (the disconnect-detection latency) is **host-tunable**
> (`PUNKTFUNK_IDLE_TIMEOUT_MS` / `--idle-timeout-ms`, default 8 s). **Remaining Stage 5 = hardware-gated
> residuals only**: the per-group physical-restore EFFECT (needs a monitor-attached Linux box — the
> headless validation boxes report `also_disabled=[]`, so nothing is disabled to restore), wlroots
> `exclusive` (needs a Sway box), Mutter `APPLY_TEMPORARY` disconnect-revert. See the **Status —
> handoff** block under §11 for the per-stage state and key decisions (notably the Windows `reject`
> default).
> This doc designs a **policy layer on top of the
> existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive
> after disconnect), topology (primary / exclusive), conflict handling (what happens when a second
> client wants a different mode), stable display identity (so desktop environments remember
> per-client settings like scaling), and **multi-monitor** (several virtual displays forming one
> desktop, fed by one client or by several). The `VirtualDisplay` trait and the per-backend
> `create()` mechanics stay as they are; this layer decides *when* to create, *how many*, *how
> long* to keep, *what else* to do to the topology, and *under which identity*.
Companion docs: `design/implementation-plan.md` §6 (virtual displays), `design/vrr-plan.md`
(pacing — out of scope here), `design/gamescope-multiuser.md` (per-session isolation — adjacent,
not required).
## 1. Goal
Today the virtual-display behavior is hardcoded per platform and per backend:
- A session's virtual output is created at connect and torn down (RAII) at session end — a
disconnect destroys the display, reshuffles the desktop, and (on gamescope bare-spawn) **kills
the running game**.
- "Make the streamed output the sole desktop" is an env knob on Linux
(`PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY`, default-on for the
auto-detected desktop path) and default-on on Windows (`PUNKTFUNK_NO_ISOLATE` to opt out) —
and on Linux "primary" and "disable the other outputs" are conflated into one switch.
- What happens when a second client connects is an emergent property of the platform: Linux
creates a second output (multi-view), Windows **reconfigures the shared monitor under the
live session** (join-path `reconfigure` in `vdisplay/windows/manager.rs::acquire`), GameStream
preempts.
- Only Windows gives a client a stable monitor identity (`vdisplay/windows/identity.rs`), so only
Windows reapplies per-client display config (DPI scaling) across reconnects. On KDE every
session's output is `Virtual-punktfunk` at whatever mode — scaling has to be re-set per connect
and is shared across every client.
- One session = exactly one display. A client with two physical monitors can only stream one;
a tablet can't join an existing streamed desktop *as a second monitor* on purpose (the Linux
multi-view behavior half-does it by accident, with no layout control).
Goal: **one shared, documented configuration surface** — a small set of orthogonal options with
safe defaults and selectable presets, stored host-side, editable from the web console, applied
uniformly across the punktfunk/1 and GameStream paths and across all five backends (KWin,
gamescope, Mutter, wlroots, Windows pf-vdisplay), each backend implementing what it can and
**honestly declining** what it can't (the same honest-downgrade convention as 4:4:4/10-bit).
## 2. What exists today (inventory)
The asymmetry worth internalizing: **Windows already has most of the machinery; Linux has none.**
| Mechanism | Windows (pf-vdisplay) | Linux (kwin/mutter/wlroots) | gamescope |
|---|---|---|---|
| Lifecycle owner | `VirtualDisplayManager` singleton — `Idle / Active{refs} / Lingering{until}` state machine, gen-stamped `MonitorLease` | none — session owns `VirtualOutput.keepalive`, capturer drop = teardown | managed path: debounced TV-session restore (`RESTORE_DEBOUNCE` 5 s) + warm-session reuse; spawn path: child dies with the session |
| Keep-alive after disconnect | linger, default 10 s (`PUNKTFUNK_MONITOR_LINGER_MS`) | none | managed: 5 s debounce (hardcoded) |
| Reuse on reconnect | join Active (refcount++) / adopt Lingering (with a dead-swapchain preempt for IDD) | none (always create fresh) | managed: reuses the warm session |
| Primary / exclusive | `isolate_displays_ccd` (exclusive), default on, restore on teardown | `apply_virtual_primary` = primary **and** disable others, env-gated, restore on drop; Mutter `make_virtual_primary` = sole monitor (APPLY_TEMPORARY) | n/a (own nested session) |
| Mode conflict | join-path silently reconfigures the shared monitor (last-wins) | each session gets its own output (multi-view) | managed: one session; spawn: one gamescope per client |
| Stable identity | `identity.rs` — cert-fp → id 1..=15 (EDID serial + ConnectorIndex), LRU, persisted `pf-vdisplay-identity.json` | none — KWin output always named `punktfunk`, sway `HEADLESS-N`, Mutter auto-serial | n/a |
| Multi-monitor | manager is single-monitor (driver supports 16 connectors) | N outputs happen to coexist (multi-view), no layout/group semantics | single-output nested session |
Design consequence: the plan is **not** "build a manager" — it's (a) extract the state machine
Windows already proved into a platform-neutral, unit-testable core, (b) give Linux the ownership
split it's missing (manager owns the keepalive, session holds a lease), (c) put a typed policy
in front of both, (d) extend identity to Linux where the compositor allows it, and (e) grow the
slot model into display **groups** so multi-monitor is an arrangement of slots, not a new system.
## 3. Architecture
Three new pieces, layered strictly **above** the `VirtualDisplay` trait (no backend rewrite):
```
┌────────────────────────────────────────────┐
mgmt API / console │ DisplayPolicy (vdisplay/policy.rs) │ pure config: schema,
host.env compat ───▶│ presets · layout · validation · persist │ presets, env-compat
└───────────────┬────────────────────────────┘
│ read per acquire/release (live-reload)
┌───────────────▼────────────────────────────┐
punktfunk/1 session │ DisplayRegistry (vdisplay/registry.rs) │ host-lifetime singleton:
GameStream session ─▶ acquire(identity, mode) → DisplayLease │ owns ManagedDisplay slots
mgmt /display/state │ release(lease) · linger timer · groups │ grouped per desktop,
└───────┬────────────────────────┬───────────┘ drives the pure Lifecycle
│ create()/drop keepalive │ reconfigure/topology/layout ops
┌────────────▼──────────┐ ┌──────────▼───────────────┐
│ Linux backends │ │ Windows │
│ kwin · gamescope · │ │ VirtualDisplayManager │
│ mutter · wlroots │ │ (existing; delegates its │
│ (unchanged trait) │ │ state decisions upward) │
└───────────────────────┘ └──────────────────────────┘
```
- **`vdisplay/policy.rs`** — the typed config (`DisplayPolicy`), preset expansion, JSON
persistence (`<config>/display-settings.json`, the `gpu-settings.json` pattern: sanitize on
load, atomic tmp+rename write), and the deprecated-env-knob mapping. 100 % pure and
unit-tested (the `pick_gamescope_mode` / `wiring_plan.rs` discipline).
- **`vdisplay/lifecycle.rs`** — the pure state machine: per-slot
`Idle / Active{refs} / Lingering{until} / Pinned` plus the **admission decision function**
(given: policy, requesting identity, requested mode(s), current slots → `Create | Reuse |
Reconfigure | Join{at_mode} | Steal{victims} | Reject{reason}`). No I/O, no OS types — fully
proptest/unit-testable, shared verbatim by both platforms. `Pinned` is `Lingering` with no
deadline (keep-alive **forever**), releasable only via mgmt/teardown.
- **`vdisplay/registry.rs`** — the host-lifetime singleton that owns `ManagedDisplay` slots
(the backend `VirtualOutput` **including its `keepalive`**, the identity slot, current mode,
group membership, topology-restore state) and executes the lifecycle decisions: calls
`VirtualDisplay::create`, holds keepalives past session end, runs the linger timer, applies
layout, exposes the mgmt snapshot. On Windows it wraps the existing `VirtualDisplayManager`
(which keeps its driver/CCD/preempt specifics — the IDD dead-swapchain preempt, the
WUDFHost-death preempt, `begin_idd_setup` — but reads its linger duration and join/steal
behavior from the policy instead of env/hardcode).
### The ownership split (the one real refactor)
Today `capture::capture_virtual_output(vout, …)` consumes the whole `VirtualOutput` — the
capturer owns the keepalive, so capturer drop tears the display down. That coupling is exactly
what makes keep-alive impossible on Linux. Split it:
```rust
pub struct DisplayLease { /* registry handle + gen stamp; Drop = release(refcount--) */ }
pub struct CaptureSource { // what capture actually needs — Copy-ish, no ownership
pub node_id: u32,
pub remote_fd: Option<OwnedFd>, // Mutter portal daemon (dup'd per capture attach)
pub preferred_mode: Option<(u32, u32, u32)>,
#[cfg(windows)] pub win_capture: Option<WinCaptureTarget>,
}
// registry.acquire(...) -> (DisplayLease, CaptureSource)
```
The `keepalive: Box<dyn Send>` moves into `ManagedDisplay` inside the registry. The session's
pipeline holds the `DisplayLease` (mirrors the Windows `MonitorLease`, gen-stamped so a stale
lease from a preempted display is a release-no-op — the proven pattern). `build_pipeline`'s
`vd.create(mode)` call sites (`punktfunk1.rs`, `gamestream/stream.rs`, `spike.rs`) become
`registry::acquire(...)`. Every failure/retry path keeps its shape — the retry-hold lease trick
in `build_pipeline_with_retry` maps 1:1 onto a `DisplayLease`.
**Re-capture on reuse** is per-backend (see §7): wlroots re-runs portal capture of the still-
existing output; KWin/Mutter reconnect a PipeWire consumer to the kept node (validation item);
gamescope re-discovers the nested compositor's node; Windows already re-targets. If re-capture
of a kept display fails, the registry falls back to **teardown + fresh create** (bounded, inside
the existing `build_pipeline_with_retry` budget) — keep-alive is an optimization, never a new
failure mode.
## 4. The configuration surface
### 4.1 Schema (`<config>/display-settings.json`)
```json5
{
"version": 1,
// Convenience: a named preset. "custom" (or absent) = the explicit fields below rule.
// When a preset IS named, the fields below are ignored (the console writes one or the other).
"preset": "custom",
// How long a display (and, on gamescope, the nested session + game) survives after the last
// session detaches. "off" = teardown at session end. "forever" = until host stop / explicit
// release. Duration is seconds.
"keep_alive": { "mode": "duration", "seconds": 300 }, // "off" | {"duration", seconds} | "forever"
// What the host does to the box's display topology while virtual displays are up:
// "extend" add the virtual display(s), touch nothing else
// "primary" make the group's primary virtual display the OS primary; physical outputs
// stay enabled
// "exclusive" the managed virtual displays become the ONLY enabled outputs (physicals
// disabled, restored when the group's last display is torn down)
// "auto" today's behavior: exclusive on the auto-detected desktop path & Windows,
// extend when the operator pinned a compositor/env said otherwise
"topology": "auto",
// Admission when a client connects while another client's display/session is live and the
// requested mode differs (same-client reconnect ALWAYS reuses/reconfigures its own display):
// "separate" give the new client its own virtual display ON THE SAME DESKTOP (bounded by
// max_displays) — this is also the "many clients as monitors" mode, see §6A
// "steal" stop the existing session(s), tear down / reconfigure, serve the new client
// "join" admit the new client AT THE EXISTING MODE (Welcome/serverinfo reflect the
// real mode — the honest-downgrade convention); never reconfigures under a
// live session
// "reject" refuse the new client with a clear handshake error
"mode_conflict": "separate",
// Stable display identity → desktop environments persist per-display config (KDE scaling):
// "shared" one identity for everything (today's Linux behavior)
// "per-client" one identity per paired client cert fingerprint (today's Windows);
// a multi-display client (§6B) gets one identity per (client, display #)
// "per-client-mode" one identity per (client, WxH) — distinct scaling per resolution,
// at the cost of identity slots (Windows has 15; LRU eviction)
"identity": "per-client",
// How the group's displays are arranged in the desktop coordinate space (§6.2):
// "auto-row" left-to-right in acquire order, top-aligned (deterministic default);
// a §6B client's own monitor-arrangement hints override auto placement
// "manual" per-identity-slot offsets below (console-arranged); wins over client hints
"layout": { "mode": "auto-row", "positions": { /* "<slot>": {"x": 0, "y": 0} */ } },
// Upper bound on simultaneously-live virtual displays (Active + Lingering + Pinned, across
// the whole group). Admission returns Reject/Steal (per mode_conflict) when full; a §6B
// AddDisplay beyond it is declined. Windows is additionally capped by the driver (see §7).
"max_displays": 4
}
```
Deliberate non-options (rejected):
- **Per-client policy overrides** — real, but v2. One host-global policy first; the schema keys
are chosen so a later `"clients": {"<fp>": {…}}` overlay is additive.
- **Idle timeout for Pinned displays** ("forever but tear down after 24 h") — `keep_alive`
already expresses it as a long duration; don't add a second axis.
- **Choosing the linger for capture-loss separately from clean disconnect** — the registry only
sees "last lease released"; the session layer already distinguishes and (see §5.1) an explicit
client **quit** bypasses keep-alive entirely.
- **Per-display FEC/bitrate policy knobs** — bitrate stays session-negotiated per stream as
today; a multi-display session's per-display bitrates are the client's ask, not host policy.
### 4.2 Precedence & live-reload
`display-settings.json` (console-written) **>** deprecated env knobs **>** built-in defaults —
the exact precedence convention the GPU preference set (`console preference >
PUNKTFUNK_RENDER_ADAPTER > auto`). The policy is **read at each acquire/release**, not once at
startup (it's file/registry state, not env — no `HostConfig` constraint), so a console change
applies to the next connect/disconnect without a host restart, same contract as the GPU card
("applies to the next session"). Env-knob compatibility mapping (all logged as deprecated when
they take effect):
| Legacy knob | Maps to |
|---|---|
| `PUNKTFUNK_MONITOR_LINGER_MS` | `keep_alive = duration(ms/1000)` (Windows) |
| `PUNKTFUNK_NO_ISOLATE` | `topology = "extend"` (Windows) |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `topology = "exclusive"` when truthy, `"extend"` when explicitly `0` |
The `apply_session_env` default-on write of `*_VIRTUAL_PRIMARY` for the auto-desktop path is
**replaced** by `topology = "auto"` resolving to exclusive on that path — one fewer process-env
mutation on the connect path (a small win for the env-race surface `ENV_LOCK` guards).
### 4.3 Presets
Presets are the documented, supported entry point; raw fields are the escape hatch. Expansion
lives in `policy.rs` and is unit-tested so docs and code can't drift.
| Preset | keep_alive | topology | mode_conflict | identity | layout | Story |
|---|---|---|---|---|---|---|
| `default` | 10 s | auto | separate | per-client | auto-row | Today's behavior, made explicit: short linger absorbs client hiccups/reconnects, streamed output is the sole desktop on the auto path, extra clients get their own view. |
| `gaming-rig` | forever | exclusive | steal | per-client | auto-row | Dedicated headless/couch box: the game and its display survive disconnects indefinitely; whoever connects takes the box over ("the TV model"). |
| `shared-desktop` | off | extend | separate | per-client | auto-row | Streaming a desktop someone may also use physically: never blank the real monitors, never keep ghost outputs, concurrent viewers each get a view. |
| `hotdesk` | 5 min | exclusive | reject | per-client-mode | auto-row | One user at a time with fast reattach (roaming between own devices); a second user is told the box is busy; each device+resolution keeps its own scaling. |
| `workstation` | 5 min | exclusive | separate | per-client | manual | The multi-monitor daily driver: your dual-monitor client gets both displays back exactly where you arranged them (§6B), or a tablet joins as a side monitor (§6A). |
## 5. Option semantics in detail
### 5.1 `keep_alive`
**What survives.** The *display* (compositor output / IddCx monitor / spawned gamescope) and its
topology state survive; the *session* (QUIC conn, capture stream, encoder, input devices, audio
plumbing) does not. Concretely per backend, "the display survives" means:
- **kwin / mutter / wlroots**: the output stays in the layout → windows don't reshuffle, a
running game keeps rendering at the client's mode, reconnect is fast (no create/negotiate).
- **gamescope (bare spawn)**: the nested gamescope **and the game launched inside it keep
running** — this is the headline user value (Sunshine/Apollo-style detach/reattach) and the
reason `keep_alive` is worth building at all.
- **gamescope (managed)**: the policy duration replaces the hardcoded 5 s
`RESTORE_DEBOUNCE` — the warm Steam session stays up for the window; `forever` means the TV
session is never auto-restored (release via console/tray).
- **Windows**: the existing linger, plus `forever` = the `Pinned` state — **shipped** (`ccbd7e8`,
`MgrState::Pinned`; compile-verified on `.173`, on-glass Windows Pinned pending). Freed via
`POST /display/release` (`force_release` handles Pinned) — the §8 escape hatch. `gaming-rig` (the
`forever` preset) is no longer mgmt-rejected and is enabled in the console; **on-glass validated on
Linux** (`.116` KWin: normal disconnect → `pinned`, no expiry; Release frees it).
**Rules.**
- Input devices (uinput pads, libei/EIS contexts) stay session-scoped — a disconnect reads to
the game as "controller unplugged", which games handle. (Keeping pads alive for kept sessions
is a possible later refinement; do not build it now.)
- The **launch command runs once per display creation, never per attach** — a reconnect to a
kept gamescope must not double-launch the game. Today launch already happens once per
`build_pipeline`-successful session; the invariant moves with the create into the registry.
- An explicit client **quit** (a user "stop", not a network drop) bypasses keep-alive: tear down
now. **Implemented on punktfunk/1** (`b53710d`, on-glass validated): the client closes the QUIC
connection with `QUIT_CLOSE_CODE` (0x51, shared in `core::quic`); the host reads the
`ApplicationClosed` reason and does `registry::release(force_immediate)``Linger::Immediate`
teardown, skipping the linger. `NativeClient::disconnect_quit()` + `punktfunk-probe --quit` drive
it; GameStream `cancel`/quit-app (`h_cancel`) + the five real clients sending the code are
follow-ups. A plain disconnect / connection loss honors the policy (lingers for reconnect).
- A **same-client reconnect resumes** (never a fresh second display). A reconnect while the client's
own prior session is still `Active` — its QUIC idle timer hasn't fired, and detection lags a drop by
`max_idle_timeout` (default 8 s, host-tunable via `PUNKTFUNK_IDLE_TIMEOUT_MS` / `--idle-timeout-ms`)
— is recognised by `admission::preempt_same_identity` (same cert fingerprint): the host signals the
zombie's stop + waits the release grace, so it lingers and the reconnect **reuses** the kept display.
Without this, a reconnect inside the detection window landed on a fresh second display while the old
session kept streaming. **Implemented + on-glass validated** (`b53710d`); implements the "preempts
downstream" the admission layer already promised (§5.3).
- Host shutdown tears everything down (RAII on exit, as today). A host crash leaves whatever
the OS reclaims — Wayland connections die with the process (compositor reclaims outputs),
spawned gamescopes die with the process group, the pf-vdisplay watchdog reaps monitors when
pings stop. No new orphan class.
- `keep_alive` + `topology=exclusive` means **physical monitors stay dark after disconnect**
until linger expiry / release. This is intended (gaming-rig) but must be loud in the docs, and
the release-now escape hatch (§8) must exist in the same release that ships `forever`.
### 5.2 `topology`
Splits the currently-conflated "primary" knob into three honest levels, **group-aware** (§6.1):
"exclusive" means *the managed virtual displays* are the only enabled outputs — never disable a
sibling slot; restore fires when the group's last display drops. Per-backend mapping:
| | extend | primary | exclusive |
|---|---|---|---|
| KWin | no-op | `kscreen-doctor output.X.primary` only | primary + disable non-managed others (today's `apply_virtual_primary` with a registry-driven filter, §6.1), restore-on-teardown |
| Mutter | no-op | `ApplyMonitorsConfig` incl. physicals, virtual primary | today's sole-monitor config (`make_virtual_primary`) extended to include all group members |
| wlroots | no-op | **unsupported** (no primary concept) → log + treat as extend | `swaymsg output <phys> disable` + re-enable on teardown (new, small) |
| gamescope | n/a — the nested session *is* the whole world; all three resolve to no-op | | |
| Windows | skip isolate (today's `PUNKTFUNK_NO_ISOLATE`) | CCD primary-only variant (new, small — `set_active_mode` already exists; primary without deactivation) | today's `isolate_displays_ccd`, extended to isolate to the SET of managed targets |
Restore stays bound to **display teardown** (keepalive drop / `teardown()`), not session end —
already true everywhere; keep-alive inherits it for free. The KWin restore-before-reclaim
ordering (re-enable others *first* so KWin never sees zero enabled outputs) is preserved.
`auto` resolves at acquire time: exclusive on Windows and on the Linux auto-detected-desktop
path, extend under an explicit `PUNKTFUNK_COMPOSITOR` pin (the CI/test posture) — bit-for-bit
today's defaults, so `default` preset = no behavior change.
### 5.3 `mode_conflict`
Enforced at **admission**, before the Welcome / RTSP launch, in the lifecycle decision function
— so the client gets an honest answer, not a mid-build failure:
- Applies only across **different clients** (identity ≠ identity). A same-client reconnect
always preempts its own zombie session / adopts its own kept display and reconfigures it to
the newly requested mode (today's behavior, now uniform on all platforms).
- `separate` — allocate another slot in the desktop group (Linux multi-view today, upgraded
with layout — §6A; Windows: **requires the multi-monitor manager, §6.6** — until that stage
lands, `separate` on Windows resolves to `join` with a startup + docs warning rather than
silently doing something else).
- `join` — the second client is admitted at the live display's mode. punktfunk/1: the Welcome's
`Config` carries the real mode (the client already renders what the Welcome says — the
4:4:4/10-bit honest-downgrade pattern). GameStream: serverinfo/RTSP negotiate the live mode.
**This replaces the Windows join-path's silent last-wins `reconfigure` under a live session**
— that current behavior becomes opt-in as `steal`.
- `steal` — signal the victim sessions' stop flags (the machinery `begin_idd_setup` already
uses), wait the release grace, tear down or reconfigure, admit. Trust note: conflict policy
runs **after** the pairing gate, so on a default host only paired clients can steal; on an
`--open`/TOFU host any accepted client can — the docs call this out and recommend `reject`
for open hosts.
- `reject` — punktfunk/1: a typed handshake refusal (extend the existing error path with a
`busy` reason string carrying the live mode + client label so the client UI can say "host is
streaming 2560×1440 to <name>"); GameStream: the 503/session-in-use answer Moonlight already
understands.
Interaction with `--max-concurrent` (session bound) is unchanged and orthogonal: sessions and
displays are different resources; `max_displays` bounds displays, the accept-loop permit bounds
in-flight sessions. `join` deliberately lets N sessions share one display (that's today's
Windows concurrency model).
### 5.4 `identity` — stable displays, persistent scaling (the KDE ask)
Two halves: an **identity map** (who gets which slot) and a **per-backend identity carrier**
(how a slot becomes something the DE keys its config on).
**Map** — generalize `vdisplay/windows/identity.rs` (it's already pure + unit-tested) into a
platform-neutral `vdisplay/identity.rs`: key = client cert fp (plus display ordinal for a §6B
multi-display client, plus WxH under `per-client-mode`), value = small stable slot id, LRU
eviction at the platform cap, persisted `<config>/display-identity.json` (Windows migrates
`pf-vdisplay-identity.json` on first load — read old path if new absent, write new).
Anonymous/unpaired clients stay slot 0 = auto/shared. **GameStream clients get identities too**
(improvement over today): the paired GameStream client cert fingerprint feeds the same map, so a
Moonlight device also keeps its scaling — today `set_client_identity` is only wired on the
punktfunk/1 path.
**Carriers per backend:**
- **Windows** — shipped: slot → EDID serial + IddCx ConnectorIndex; Windows keys
`PerMonitorSettings` (DPI scaling) on exactly that. Cap 15 (ConnectorIndex <
MaxMonitorsSupported=16). `per-client-mode` and per-display ordinals work unchanged but burn
slots faster — the LRU already handles pressure; document the trade-off.
- **KWin** — the carrier is the **output name**: `stream_virtual_output(name, …)` becomes
`punktfunk-<slot>` → output `Virtual-punktfunk-<slot>`. KWin persists per-output config
(scale, transform, mode) in `kwinoutputconfig.json`, matching EDID-less outputs **by name**
so a stable per-client name is precisely what makes KDE reapply that client's scaling.
Two validation items before relying on it (Stage 3 gate, §11):
1. confirm KWin ≥ 6.5.6 actually persists + reapplies scale for `Virtual-*` outputs;
2. confirm a *remembered mode* doesn't fight the freshly requested one (if KWin reapplies a
stale stored mode on output-added, our existing `set_custom_refresh`/mode apply must run
after and win — it already reads back the achieved mode, so a fight is at least visible).
Side effect worth having: distinct names also unclash concurrent sessions (today two
simultaneous KWin sessions both create `Virtual-punktfunk` and `set_custom_refresh` /
`other_enabled_outputs` match **by that shared name** — a latent multi-view bug this fixes).
- **wlroots** — no rename and no settable description via IPC; headless outputs are
`HEADLESS-N` by creation order. Identity is therefore **not reliably carriable** → declared
unsupported (`shared` behavior regardless of setting; capability matrix + docs say so). The
single-session case is de-facto stable (`HEADLESS-1`), which users can pin in sway config —
document that recipe instead of pretending.
- **Mutter**`RecordVirtual` auto-generates the virtual monitor's serial; no public D-Bus
surface to control it → unsupported for now. Note for later: re-evaluate Mutter's
virtual-monitor D-Bus surface per GNOME release (tracked as an open item, not a promise).
- **gamescope** — n/a: the client streams a whole nested session; scaling inside it is per-game.
**Scale as a punktfunk-side option (small, high-value adjunct):** KWin's
`stream_virtual_output` takes a `scale` argument we currently hardcode to `1.0`. Add an optional
per-client `default_scale` (console-editable next to the device list) passed at create on KWin;
on Windows scaling stays the OS's job (identity makes it persist). This gives HiDPI phones/
tablets a correct-sized desktop on first connect, before any DE-side persistence exists. A
client-requested scale hint in the Hello (trailing-byte back-compat, like the gamepad-pref byte)
is future protocol growth — design it when a client actually wants to send it.
## 6. Multi-monitor
Two scenarios, deliberately separated because they differ ~10× in cost:
- **§6A — many clients, one desktop ("second screen")**: each client device becomes one more
monitor of the same host desktop (tablet as a side monitor next to the laptop's stream).
Structurally this already half-exists on the Linux desktop compositors (`separate` gives
every client its own output on the shared desktop); what's missing is *intent*: layout
control, group-aware topology, and honest per-backend gating. **No protocol change** — it
ships on the registry work.
- **§6B — one client, many displays**: a client with two physical monitors gets two virtual
displays, streamed as two video planes, presented one-per-monitor, arranged on the host to
mirror the client's physical arrangement. Needs protocol growth, N encoder pipelines, client
presenter work, and (on Windows) the multi-monitor manager. **punktfunk/1-native only**
GameStream/Moonlight has no multi-display vocabulary and stays single-stream.
### 6.1 Display groups (registry concept, serves both)
`ManagedDisplay` slots gain a **group**: the set of displays sharing one desktop/session.
- kwin / mutter / wlroots: one group per compositor session — every acquired slot joins it
(that *is* the shared desktop).
- gamescope spawn: one group per spawned nested session. gamescope is single-output — a §6B
client asking N displays there resolves to 1, honestly (the extra `AddDisplay`s are declined).
- Windows: one group (the desktop); slots = IddCx monitors (§6.6).
Group-aware semantics — these fix latent issues even before multi-monitor ships:
- **`exclusive` disables only non-managed (physical/bootstrap) outputs, never group members.**
Today's KWin `apply_virtual_primary` disables "everything not named `Virtual-punktfunk`" —
under Stage-3 per-slot names, a second session's exclusive would disable the *first* session's
live output. The filter must consult the registry (the set of managed output names), not one
hardcoded name. Same shape on Windows (`isolate_displays_ccd` isolates to the managed target
*set*) and Mutter (the sole-monitor config includes all group members).
- **`primary` designates one group member** — for §6B the client marks which of its displays is
primary (its OS already knows); for §6A the first slot wins unless the console re-designates.
- **Topology restore is per-group, not per-display** — the saved pre-stream config is restored
when the group's **last** member drops, never while siblings live. (Windows `SavedConfig` and
the KWin `restore` vec move from `Monitor`/`StopGuard` into the group record.)
### 6.2 Layout
The `layout` policy block (§4.1) controls where group members sit in the desktop space:
- `auto-row` (default): left-to-right in acquire order, top-aligned — what compositors mostly
do anyway, made deterministic.
- `manual`: per-identity-slot offsets, console-edited (an OS-settings-style drag mini-map is
the stretch UI; an x/y table ships first). Keyed by identity slot, so *client B's tablet
always reappears to the right of client A's monitor* — layout + identity compose.
- A §6B client sends its real monitor arrangement as per-display position hints; they override
`auto-row` (mouse crossing between streamed monitors then matches the client's physical
layout) but lose to `manual` pins.
Backend mapping — all existing tooling, no new protocols: KWin
`kscreen-doctor output.X.position.x,y` (validate syntax the way `set_custom_refresh` did);
wlroots `swaymsg output <n> position X Y`; Mutter logical-monitor positions in the same
`ApplyMonitorsConfig` we already build; Windows CCD source origins in the same
`SetDisplayConfig` path `isolate_displays_ccd` uses.
**Host-side input routing.** §6A needs nothing (N clients inject into one desktop — already
true today). §6B needs the injectors to map `(display, x, y)` → desktop coordinates using the
group layout: per-backend work items — libei absolute positioning is per-region, the wlr
virtual-pointer protocol binds to an output, Windows `SendInput` absolute is desktop-normalized
(pure math off the group layout). Wire change in §6.3.
Two realities to document, not engineer around: **cursor rendering is already correct** (every
backend embeds the cursor per-output — KWin `POINTER_EMBEDDED`, the IDD's per-monitor
composition — so it appears only on the stream it's on and "crosses" between monitors
naturally), and **a §6A desktop has one cursor shared by all member clients** — exactly right
for the one-user-two-devices case (touch the tablet, the cursor jumps there), chaotic for two
people; genuinely independent users want gamescope multi-user
(`design/gamescope-multiuser.md`), not groups.
### 6.3 Protocol growth for §6B (punktfunk/1 only)
Principle: **a display is one data-plane instance.** Don't touch the hardened core packet
format — N displays = N × (encoder + send thread + core `Session` over its own UDP flow), one
shared QUIC control connection, one set of session-scoped side planes (audio, mic, rumble,
input). And **don't grow the Hello**: the handshake's back-compat idiom is single trailing
bytes — a variable-length display list doesn't fit it, and it doesn't need to, because the
control stream stays open after `Start` (Reconfigure/ClockProbe already ride it).
- **Capability**: client advertises `VIDEO_CAP_MULTI_DISPLAY` (`video_caps` bit `0x10`); the
Welcome echoes the host's per-session display budget as one trailing byte (`max_displays`
remaining, `0`/absent = single-display host — old hosts are automatically honest).
- **Negotiation**: the Hello/Welcome pair is untouched and establishes **display 0** exactly as
today (an old host serves a multi-monitor-capable client's primary display with zero special
cases). Extra displays negotiate post-`Start` on the control stream:
`AddDisplay { mode, position_hint, primary: bool } → DisplayAdded { index, config /* the same
honest per-display Config shape the Welcome carries: mode, bit depth, chroma, codec */ }` or
`DisplayDeclined { reason }`. `RemoveDisplay { index }` and a per-display `Reconfigure`
(index as a trailing byte on the existing message) complete the set — **client monitor
hotplug maps 1:1 onto Add/Remove mid-session.**
- **Data plane**: `DisplayAdded` carries the flow binding (host UDP port / flow token) for that
display's own core `Session`. Per-flow crypto derives the AES-GCM nonce salts per
(direction, display index) — no salt reuse across flows; FEC domains are independent per flow
(loss on one display can't stall another) — this is why "one Session per display" beats
muxing display ids into the core packet format.
- **Side planes**: pointer/touch events gain a display-index byte (same trailing-byte pattern
as the gamepad pref; absent = display 0); 0xCF host-timing and 0xCE HDR-metadata datagrams
gain the index the same way (a client mixing an HDR laptop panel + SDR external monitor gets
per-display grades). Audio/mic/rumble/gamepad stay session-scoped, untouched.
- **Per-display honesty**: each display negotiates bit depth/chroma/codec independently through
the same resolve functions — a host that can afford HEVC Main10 on one head and only 4:2:0 on
the second says so in each `DisplayAdded.config`.
- **Stats**: the stats-unification vocabulary (four measurement points, p50/p95 windows) gains
a display dimension — per-display series, HUD shows the focused display's equation
(`design/stats-unification.md` gets a §6B addendum; don't invent client-local stats).
- **C ABI / connector**: `punktfunk_add_display` / per-display `next_au` routing (an index out
param on the existing call keeps the ABI additive), so PunktfunkKit/JNI stay on the shared
connector.
### 6.4 Encoder & resource budget
N displays = N encode pipelines. NVENC consumer session caps — and the existing auto 2-way
**split-encode** above ~1 Gpix/s consuming *two* NVENC sessions for one stream — mean admission
must budget: `DisplayAdded` is granted only if the encoder backend confirms capacity (extend the
existing NVENC session accounting + the AMF/QSV probes with a `can_open_another()` check), and
**split-encode is disabled for multi-display sessions** (displays win over split; a 5K@240
single head is not the multi-monitor use case). `max_displays` bounds the group. Same idle-cost
note as keep-alive: every added display composites + encodes at full rate. Bandwidth is
per-display additive (two 4K heads ≈ 2× the bitrate): the per-host speed test's recommendation
should be read **per session** and split across that session's displays — the client divides
its ask, the host doesn't second-guess it (per-display bitrate is deliberately not host policy,
§4.1).
### 6.5 Client staging for §6B
- **Linux GTK + Windows clients first** — natural multi-window presenters: one
window/fullscreen surface per display on the matching physical monitor, the existing capture
state machine extended to span them (pointer crossing between our fullscreen windows must not
release capture).
- **macOS second** (multi-NSWindow across Screens; Spaces/fullscreen interplay is the risk).
- **Android/iOS/tvOS: never advertise the capability** — single-display presenters. A phone or
tablet still participates in multi-monitor via §6A (it *is* a second monitor), which needs
nothing from those clients.
### 6.6 Windows multi-monitor manager
Previously an explicit non-goal; now a designed **final stage** — the single-monitor manager
keeps working unchanged until it lands:
- **Manager**: the singleton's `MgrState` becomes a map keyed by connector id; `lifecycle.rs`
is already written per-slot, so the Windows manager's delegation doesn't change shape. The
IDD reconnect preempts (dead-swapchain, WUDFHost-death) become per-slot.
- **Driver**: pf-vdisplay already ADDs by connector id 1..=15 (the identity map's bound). The
sealed frame channel (`IOCTL_SET_FRAME_CHANNEL`) must become **per-monitor** — channel
messages carry the monitor id, reusing the multi-pad `pad_index` pattern (driver proto v3;
`design/idd-push-security.md` addendum: same unnamed-object + handle-dup broker per ring).
Driver work + CI + on-glass validation is exactly why this stage is last.
- **Capture/encode**: one IDD-push capturer per monitor ring; budget per §6.4.
- **CCD**: isolate/primary/layout already group-aware from §6.1/6.2.
## 7. Per-backend capability matrix
What each backend supports; unsupported cells resolve to the stated fallback and are surfaced in
`GET /api/v1/display/state` per display (`"capabilities": [...]`) so the console can grey options
out per-host instead of lying:
| Capability | KWin | gamescope spawn | gamescope managed | gamescope attach | Mutter | wlroots | Windows |
|---|---|---|---|---|---|---|---|
| keep-alive (linger/forever) | ✅ hold the vout thread; re-attach PipeWire consumer to the kept node — **validate** | ✅ nested session + game survive; re-discover node | ✅ policy replaces the 5 s debounce | — (never owned it) | ✅ hold the D-Bus session; consumer re-attach — **validate** | ✅ output persists; fresh portal capture per attach (cleanest) | ✅ shipped incl. `Pinned` (forever) |
| reconfigure kept display to a new mode | ✅ `set_custom_refresh` + kscreen mode | ✅ SIGKILL+respawn is the honest "reconfigure" (game restarts — docs say so) or decline → recreate | ✅ existing managed-mode set | — | ⚠ node is sized by negotiation; renegotiation unproven — fallback recreate | ✅ `output <n> mode --custom` | ✅ `reconfigure()` shipped |
| topology: primary | ✅ | n/a | n/a | n/a | ✅ | ❌ → extend | ✅ (new, small) |
| topology: exclusive | ✅ shipped (filter → group-aware) | n/a | n/a | n/a | ✅ shipped (→ group-aware) | ✅ (new, small) | ✅ shipped (→ group-aware) |
| mode_conflict: separate / §6A group | ✅ multi-output | ✅ one gamescope per client (independent sessions, no shared desktop) | ❌ single session → steal/join/reject only | — | ✅ assumed — **validate ≥2 RecordVirtual monitors** | ✅ HEADLESS-N | ⏳ §6.6 (until then → join + warning) |
| §6B multi-display for one client | ✅ N outputs + layout | ❌ single-output (extra displays declined) | ❌ | — | ⚠ gated on the ≥2-monitor validation | ✅ | ⏳ §6.6 |
| layout (position control) | ✅ kscreen position | n/a | n/a | n/a | ✅ ApplyMonitorsConfig | ✅ `output position` | ✅ CCD origins |
| stable identity | ✅ output name per slot | n/a | n/a | n/a | ❌ (API gives no serial control) | ❌ (no name control) | ✅ shipped |
The **attach** gamescope sub-mode never owns the display (it mirrors a foreign gamescope) — the
registry records it as an unmanaged pass-through slot: no keep-alive, no topology, no identity,
conflict = join-only. That's just codifying reality.
## 8. Management API, web console, tray
Endpoints (bearer-only, like `/gpus`; documented in `mgmt.rs`'s OpenAPI → regenerate
`api/openapi.json`):
- `GET /api/v1/display/settings``{ settings, preset_expansions, capabilities }` — the stored
policy plus what this host's live backend can actually do (so the console renders accurate
controls).
- `PUT /api/v1/display/settings` — validate (unknown fields rejected, ranges clamped like the
GPU PUT), persist atomically, log. Applies from the next acquire/release.
- `GET /api/v1/display/state` → live slots:
```json
{ "displays": [ { "slot": 3, "backend": "kwin", "output": "Virtual-punktfunk-3",
"mode": "2560x1440@120", "state": "lingering", "expires_in_s": 240,
"client": "a1b2c3…(label)", "display_index": 0, "sessions": 0,
"group": 1, "position": {"x": 0, "y": 0}, "topology": "exclusive" } ] }
```
- `POST /api/v1/display/release` `{ "slot": 3 }` or `{}` (all) — immediately tear down
Lingering/Pinned displays. **Refuses Active** (stopping a live session is session management,
not display management — don't blur it).
- `PUT /api/v1/display/layout` `{ "positions": { "<slot>": {"x":…, "y":…} } }` — the manual
arrangement (applies live to affected groups; persisted into the policy's layout block).
Web console (Host page, next to the GPU card): a **Virtual displays** card — preset selector
(radio + one-line story each, `custom` unlocking the advanced fields), the live display list from
`/state` with per-row "Release" buttons and a linger countdown, the arrangement editor (x/y
table first, drag mini-map stretch), capability-aware disabled states. The loopback
`local/summary` gains a `displays_live` count (counts only — the established no-secrets rule) so
the **tray** tooltip can show "1 display kept alive" and offer a release-all action through the
same elevation path as start/stop (Windows) / `systemctl --user` (Linux) — tray work is a
stretch stage, not core.
## 9. Enforcement points (exact code paths)
1. **punktfunk/1 handshake** (`punktfunk1.rs`, where the Hello is resolved into the Welcome):
call `registry::admit(identity, requested_mode)` → on `Reject` answer the typed refusal; on
`Join` the Welcome's `Config` carries the live mode; on `Steal` signal victims + wait release
(bounded) before proceeding. This runs **before** `SessionContext` is built.
2. **`virtual_stream` / `build_pipeline`** (`punktfunk1.rs:3511`, `build_pipeline_with_retry`):
`vd.create(mode)``registry::acquire(...) -> (DisplayLease, CaptureSource)`; the retry-hold
lease keeps its exact semantics. The mid-stream **Reconfigure**, **session-switch**, and
**capture-loss rebuild** paths re-acquire through the registry so a compositor switch
correctly releases the old backend's slot and the new mode updates the slot's record.
3. **Control stream, post-Start** (§6B): `AddDisplay`/`RemoveDisplay` handlers spawn/stop a
per-display pipeline (its own `registry::acquire`, encoder, send thread, UDP flow) inside the
same `SessionContext` lifetime; `--max-concurrent` counts sessions, not displays.
4. **GameStream** (`gamestream/stream.rs::open_gs_virtual_source`): same acquire; identity from
the paired client cert fp (new); quit-app → `release(quit=true)` which bypasses keep-alive.
5. **Session end**: capturer drop (releases the PipeWire consumer / ring) then `DisplayLease`
drop → lifecycle decides Linger/Pinned/teardown. On Linux the keepalive no longer rides the
capturer (§3 ownership split).
6. **`serve` startup/shutdown**: registry constructed once (like `start_restore_worker`), all
slots torn down on graceful exit.
## 10. Documentation plan
A dedicated docs-site page **`docs-site/content/docs/virtual-displays.md`** (+ `meta.json`
entry), cross-linked from `configuration.md`, `host-cli.md`, `steamos-host.md`, and
`troubleshooting.md`. Structure — written for the operator, presets first:
1. **What punktfunk does with displays** — 5 lines: per-client-sized virtual output, created on
connect, what "keep alive"/"exclusive" mean physically.
2. **Pick a preset** — the §4.3 table verbatim, each with a one-paragraph story and the JSON it
expands to ("copy this into display-settings.json, or click it in the console").
3. **Options reference** — one subsection per option: values, default, per-backend support
badge row, and a concrete example scenario each ("You stream from your phone at 1080p and
your TV at 4K120: with `identity: per-client` KDE remembers 150 % scaling for the phone and
100 % for the TV").
4. **Multi-monitor** — the two scenarios in user language: *"use your tablet as a second
monitor"* (§6A: connect a second device, arrange it in the console) and *"stream your
dual-monitor setup"* (§6B: which clients support it, what the host does with the layout),
plus the support matrix and the GameStream single-stream note.
5. **Persistent scaling (KDE/Windows)** — the user-visible recipe: connect once, set scaling in
System Settings / Windows Settings while streaming, done — punktfunk's stable identity makes
the DE reapply it. Honest support table (KWin ✅ / Windows ✅ / GNOME ❌ why / Sway recipe).
6. **Troubleshooting** — "my physical monitors stayed off" → release button/endpoint + the
keep_alive×exclusive explanation; "second client gets the wrong resolution" → `join`
semantics; "game restarted on reconnect" → gamescope reconfigure caveat; "second display
declined" → encoder budget (§6.4); KWin/gamescope version floors.
7. **Legacy env knobs** — the §4.2 mapping table, marked deprecated.
Also update: `README.md` status row, `CLAUDE.md` (status + invariant below), `host.env.example`
(point at the JSON/console, list deprecated knobs), and the OpenAPI snapshot.
**New design invariant for CLAUDE.md** (once shipped): *Display lifecycle is owned by the
registry, policy-driven; sessions hold leases, never the keepalive. New backends implement
`VirtualDisplay` + declare capabilities; they never grow their own lifecycle/env knobs. A
display is one data-plane instance — multi-display never muxes into the core packet format.*
## 11. Staged implementation
Each stage lands green (`cargo test/clippy/fmt`, OpenAPI drift check) and is independently
shippable; on-glass validation notes inline. **Heads-up for this box:** the dev VM currently has
no GPU passthrough (RTX 5070 Ti detached at the Proxmox level, 2026-07-01) — KWin-path live
validation needs the GPU back or one of the LAN hosts.
### Status — 2026-07-05 handoff
Branch **`display-mgmt-stage0`** (NOT merged; merge when the whole feature is polished/complete).
On-glass validation boxes: **`.173`** (Windows, pf-vdisplay + a physical monitor), **`.21`** (CachyOS
GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `fedora:43` distrobox;
`.48` Fedora KDE is DOWN). Every commit is `cargo test/clippy/fmt`-green.
- **Stages 04: DONE + on-glass validated.** 0 (policy surface + `/display/settings` + console card),
1 (pure `lifecycle.rs` + `registry.rs` Linux keep-alive pool + ownership split via `DisplayLease` +
`/display/state`/`/display/release`), 2 (topology decoupling — distinct `extend`/`primary`/`exclusive`
via `effective_topology()`), 3 (platform-neutral `identity.rs` map + `per-client-mode` + KWin per-slot
output naming → **KWin persists per-output scale by name**, proven via `kwinoutputconfig.json` on `.116`),
4 (mode-conflict admission — `vdisplay/admission.rs`, loopback-validated for all four policies).
- **Stage 5 (§6A): DONE + on-glass validated (KWin `.116` + Mutter `.21`).** All §6A group semantics
landed + unit-tested, then live-validated (group model, positions, identity keying, group-aware
exclusive/extend, 2 concurrent Mutter `RecordVirtual` monitors; the dev VM itself is GPU-less):
**display groups** (`registry::group_key` — one
per desktop backend, each gamescope spawn its own group), **group-aware exclusive/primary** (KWin
`MANAGED_PREFIX` + first-slot-wins; Mutter `set_first_in_group` → a non-first session extends rather than
re-clobbering), **per-group topology restore** (KWin hands its restore to the registry via
`take_topology_restore`; `Entry::topology_restore` + `hand_off_restore` float it to a surviving sibling
and run it only when the group empties, before the last output drops — all 3 teardown paths), the pure
**layout engine** (`vdisplay/layout.rs::arrange`, auto-row + manual) + **registry-driven `apply_position`**
(`position_for_new` over the whole group; skips the origin so the single-display path is unchanged), the
`PUT /api/v1/display/layout` endpoint (`EffectivePolicy::with_manual_layout`), and `/display/state` now
carrying `group`/`display_index`/`position`/`identity_slot`/`topology`. The registry keys the arrangement
on per-client identity via `VirtualDisplay::last_identity_slot` (KWin). The **web arrangement table**
(`DisplayCard.tsx` `DisplayArrangement`, en+de) is also done, moved to its own **Virtual displays** nav
section with a full one-click-preset config surface. **Remaining = hardware-gated residuals only:** the
per-group physical-restore EFFECT (needs a monitor-attached box — the headless boxes report
`also_disabled=[]`), wlroots `exclusive` (needs a Sway box), Mutter `APPLY_TEMPORARY` disconnect-revert —
see the Stage 5 entry below. Plus the **keep-alive reconnect hardening** (`b53710d`, on-glass validated):
same-client zombie preempt + deliberate-quit skip-linger + tunable idle timeout (§5.1) — what made
"reconnect resumes" actually hold under a fast reconnect.
**Decisions / deltas from this plan as written — read before continuing:**
- **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two
concurrent Windows sessions both drive one pf-vdisplay monitor's **single-capturer** IDD-push channel
(newest-delivery-wins) → the 2nd freezes + can WEDGE the 1st (observed live: it wedged `.173`, needed a
reboot — surfaced as Moonlight "no video"). True multi-session Windows capture is §6.6/Stage 7. So on
Windows `separate` (incl. the unconfigured default) resolves to `reject` — a 2nd client gets a clean
503, the live session is protected; `join`/`steal` are explicit opt-ins. Centralised in
`admission::effective_conflict()`, shared by the native handshake + GameStream `h_launch`.
- **Reject IS typed:** punktfunk/1 closes the QUIC connection with app code `0x42` + the reason
`"host busy: streaming WxH@Hz to <client>"`, which the client reads from `ApplicationClosed`.
- **Stage 5's group-aware exclusive fixes a bug Stage 3 introduced:** per-slot names meant a 2nd
`exclusive` session's disable-filter would black out the 1st session's `Virtual-punktfunk-<id>` output.
Fixed on KWin by recognising the whole managed group via the shared `Virtual-punktfunk` prefix.
- **GameStream 503** is implemented (owner-fp on `LaunchSession`, `gamestream_admission()` unit-tested,
shares `effective_conflict()`) but NOT Moonlight-validated (can't drive `/launch` autonomously).
**Validated since (2026-07-05):** the KWin **set-scaling ROUND-TRIP** — a client set 150 % then 125 %
in the streamed KDE session, disconnected, reconnected, and the scale was reapplied to the freshly
re-created `Virtual-punktfunk-<id>` (proven in `kwinoutputconfig.json`); this closes the Stage-3 gate.
Also the §6A group model + group-aware exclusive/extend + 2 concurrent Mutter `RecordVirtual` monitors,
and the keep-alive reconnect hardening.
**Still deferred (need a display-attached box / a specific compositor / a real client):** the `primary`
physical-keep EFFECT on Linux + a Windows primary-only CCD variant; **wlroots `exclusive`**; GameStream
503 on-glass; and the **per-group physical-restore EFFECT** — a monitor-attached box is required to see
`exclusive` disable a physical output and the group restore re-enable it only after the last member drops
(the headless boxes report `also_disabled=[]`, so the group semantics are proven but the physical
toggle isn't).
- **Stage 0 — policy + plumbing-lite. [DONE ✓]** `policy.rs` (schema/presets/persist/env-compat, fully
unit-tested), mgmt GET/PUT `/display/settings`, console card (settings only), docs page
skeleton with the presets/options tables. Behavior deltas limited to what existing knobs can
express: Windows linger reads the policy; Linux topology auto/extend/exclusive routes through
the existing primary code. *No lifecycle change yet — zero-risk adoption of the surface.*
- **Stage 1 — lifecycle core + Linux keep-alive. [DONE ✓]** `lifecycle.rs` pure machine
(+proptests: no lost teardowns, no double-frees across arbitrary acquire/release/expiry
interleavings), `registry.rs`, the ownership split (`DisplayLease`/`CaptureSource` — the one
cross-cutting refactor, touches `capture_virtual_output` signatures on both OSes), keep-alive
live for **wlroots** and **gamescope-spawn** (the two backends where reuse is structurally
trivial), `/display/state` + `/display/release`, console live-list. Windows manager delegates
linger/pinned decisions to `lifecycle.rs` (its driver specifics untouched).
*Validate:* sway on this box (headless), gamescope spawn: connect → disconnect → verify
vkcube/game still runs → reconnect → same session, no relaunch.
- **Stage 2 — topology decoupling. [DONE ✓]** Kept-node PipeWire re-attach on
KWin and Mutter (each behind its validation; fallback recreate), `primary` (without disable)
on KWin/Mutter/Windows, `exclusive` on wlroots, restore paths regression-tested.
*Validate:* headless KDE session (the `run-headless-kde.sh` rig), GNOME box .248.
- **Stage 3 — identity. [DONE ✓]** Platform-neutral identity map + migration, per-slot KWin output
naming (+ the concurrent-session name-clash fix riding along), GameStream identity wiring,
optional `per-client-mode` keying, per-client `default_scale` on KWin.
*Validated on KDE (`.116`, 2026-07-05):* a client set 150 % then 125 % in the streamed session,
disconnected, reconnected (keep-alive off → full teardown+recreate), and the scale was reapplied to
the fresh `Virtual-punktfunk-<id>` — confirmed in `kwinoutputconfig.json` (`scale=1.25` persisted by
connector name). This is the round-trip the persist mechanism was designed for. *(client-B-unaffected
under two concurrent sessions is folded into the Stage-5 two-session case.)*
- **Stage 4 — mode-conflict admission. [DONE ✓]** Decision function (`vdisplay/admission.rs`,
`decide`/`admit`/`effective_conflict`) wired into the punktfunk/1 handshake + GameStream `h_launch`,
the typed punktfunk/1 `busy` refusal (QUIC close `0x42` + reason), GameStream 503 path, `steal`
victim signaling reusing the stop-flag plumbing. **The Windows default is `reject`, NOT the
`join`/silent-reconfigure originally planned** — see the handoff Decisions above (single-capturer
IDD-push). Loopback-validated (all four policies) + `.173` reject-default validated; GameStream 503
unit-tested, Moonlight-pending.
- **Stage 5 — §6A multi-client monitors. [DONE ✓ — on-glass validated (KWin `.116` + Mutter `.21`); hardware-gated residuals deferred]** Display
groups, group-aware exclusive/primary/restore (incl. the name-filter fix), layout auto-row + manual,
`/display/layout`, console arrangement table. Cheap: rides Stages 13 infrastructure, no protocol change.
**Done:**
- KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by the
`Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group primary,
unit-tested.
- **Layout engine** (`vdisplay/layout.rs::arrange`): pure auto-row (left-to-right in acquire order,
top-aligned) + manual (per-identity-slot offsets, auto-row fallback for unpinned members),
unit-tested. `manual_position` helper for a single backend-local apply.
- **Registry group model** (Linux): group = backend (one desktop per compositor session); the
`/display/state` snapshot groups entries, orders by acquire (gen), and computes each member's
`position` via the engine. `DisplayInfo` now carries `group` / `display_index` / `position` /
`identity_slot` / `topology`. The backend reports its resolved slot via the new
`VirtualDisplay::last_identity_slot` (KWin only), so the arrangement + state key on per-client identity.
- **`PUT /api/v1/display/layout`**: persists the console's manual arrangement (positions keyed by
identity slot) via the pure `EffectivePolicy::with_manual_layout` transform (locks the current
effective behavior into explicit `Custom` fields + sets a manual layout — arranging is orthogonal to
the other axes). OpenAPI regenerated.
- **Registry-driven position apply** (`VirtualDisplay::apply_position(x, y)`, default no-op; KWin
implements it via `kscreen-doctor output.<n>.position.<x>,<y>`): the registry owns the group, so
right after `create` it computes the new display's position over the whole group via the pure
`position_for_new` (existing same-backend members in acquire order + the new one appended last →
`layout::arrange` → the new member's placement) and calls `apply_position`. This makes **both**
auto-row (deterministic left-to-right, not just the compositor's default) **and** manual placement
go through one seam. Guarded: the registry skips the desktop origin `(0, 0)`, so a single-display /
first-of-group session (and every non-KWin backend, which no-ops `apply_position`) issues no
positioning at all — the historical single-display path is byte-for-byte unchanged. `position_for_new`
is unit-tested. *On-glass-validation-pending (kscreen positioning of a live virtual output).*
- **Per-group topology restore** (design §6.1 — restore the physical only when the group's LAST member
drops): the KWin `exclusive` restore no longer rides the per-session `StopGuard` (which would re-enable
the physical the moment the FIRST of several exclusive sessions dropped, under a live sibling). KWin
now hands the restore to the registry as a closure (`take_topology_restore`); the registry keeps it in
the display **group** (`Entry::topology_restore`) and, on teardown, **floats** it to a surviving
same-group sibling (`hand_off_restore`) or, when the group empties, runs it — outside the lock, BEFORE
the last output's keepalive drops, so the compositor never sees zero outputs. All three teardown paths
(lease drop / linger expiry / mgmt release) honor it. The single-display path is byte-for-byte
unchanged (one member → run on its teardown). `hand_off_restore` is unit-tested (float / run-on-last /
non-carrier-first / never-cross-backend). *Residual concurrent-connect race + two-session on-glass
validation pending.*
- **Mutter group-aware** (`set_first_in_group`): the registry tells each backend whether it is the
FIRST display of its group; a non-first Mutter session **extends** into the already-exclusive desktop
instead of re-applying a sole-monitor `ApplyMonitorsConfig` that would disable the first session's
virtual. (Simpler than the originally-planned "include all group virtuals," which Mutter can't do —
its connectors are un-nameable — and achieves the same connect-time outcome.) Single-session unchanged
(`first == true`). *Residual: Mutter `APPLY_TEMPORARY` reverts the topology when the FIRST session
leaves under a live sibling (§7) — a full fix needs a group-owned `DisplayConfig` connection; deferred.
Concurrent-Mutter on-glass validation pending (even ≥2 `RecordVirtual` monitors is unproven).*
- **gamescope groups** (design §6.1): a gamescope **spawn** is an independent nested session per client
(no shared desktop), so `registry::group_key` makes each gamescope display its OWN group — never
auto-rowed against, topology-grouped with, or restore-grouped with another gamescope. Unit-tested.
(§6B single-output "decline extras" is Stage 6.)
- **Console arrangement table (web)** [DONE ✓]: a `DisplayArrangement` x/y editor in the `Virtual
displays` card (`web/src/sections/Host/DisplayCard.tsx`) — for a ≥2-display group it renders an x/y
table over the live displays that carry an identity slot, seeded from `/display/state`, and Save
writes `PUT /display/layout` (switches the host to a manual layout, applied next connect). en+de
i18n; the stale `display_pending_note` copy refreshed. tsc + vite build green. (Drag mini-map is a
later stretch.)
**Remaining Stage 5 — hardware-gated residuals only (no more host/web build work):**
- **On-glass validation — mostly DONE (2026-07-05, KWin `.116` + Mutter `.21`):** the group model,
per-member positions, identity keying, group-aware `exclusive`/`extend` coexistence (a 2nd session
does NOT clobber the 1st's output), and **2 concurrent Mutter `RecordVirtual` monitors** are all
confirmed live; the keep-alive reconnect path (reuse, quit-skip-linger, tunable idle) was validated
deterministically with the probe. **STILL PENDING: the per-group physical-restore EFFECT** — both
boxes are headless so `also_disabled=[]` (nothing to disable → nothing to restore); seeing
`exclusive` black out a real monitor and the group restore re-enable it only after the LAST member
drops needs a **monitor-attached Linux box**. The group-restore LOGIC (`hand_off_restore`) is
unit-tested; only the physical effect is unobserved.
- **wlroots group-aware exclusive** stays deferred: wlroots `exclusive` is not implemented at all (needs
a Sway box), so there is no topology to make group-aware yet. §6A multi-view on wlroots already works
(independent `HEADLESS-N` outputs).
- **Mutter `APPLY_TEMPORARY` disconnect-revert** (§7): when the FIRST Mutter session leaves under a live
sibling, Mutter reverts the topology — a full fix needs a group-owned `DisplayConfig` connection.
- **Stage 6 — §6B protocol + Linux host + GTK client.** `VIDEO_CAP_MULTI_DISPLAY`, control-
stream Add/Remove/DisplayAdded, per-flow nonce-salt derivation, per-display pipelines on
KWin/wlroots, input display-index routing, C ABI additions, GTK client multi-window
presenter, stats display dimension.
*Validate:* loopback probe requesting 2 displays → two decodable .h265 outs + per-display
0xCF; then a real dual-monitor Linux client against the KDE box.
- **Stage 7 — Windows multi-monitor** (§6.6: driver proto v3 per-monitor sealed rings, manager
slot map, Windows client multi-window, `separate` un-gated on Windows) — gated on driver CI +
on-glass, deliberately last.
- **Stage 8 — polish.** Docs page finalized with real console screenshots, tray count/release
(stretch), README/CLAUDE.md/host.env.example updates, `local/summary` count, macOS §6B
presenter (its own mini-stage when scheduled).
## 12. Risks & open questions
- **PipeWire node reuse after consumer detach (KWin/Mutter)** — the load-bearing unknown for
Stage 2. If a kept node won't renegotiate for a fresh consumer, keep-alive on those backends
degrades to "topology-stable but recreate-on-reconnect" (still valuable: no desktop reshuffle
when *paired with identity naming*). The fallback is designed in, so the stage can't strand.
- **KWin persistence of `Virtual-*` output config** — if KWin declines to persist virtual
outputs, per-client scaling on KDE needs punktfunk-side scale storage instead (the
`default_scale` adjunct already gives us the mechanism); identity naming stays worthwhile for
the name-clash fix alone.
- **KWin stored-mode vs requested-mode fights** under identity naming (§5.4) — mitigated by
our post-create mode apply + read-back; watch for it in Stage 3 validation.
- **Compositor ceilings on simultaneous virtual outputs** — load-bearing for §6A/§6B: probe
KWin's virtual-output count and Mutter's `RecordVirtual` count (≥2 monitors) empirically in
Stage 2/5; `max_displays` default 4 keeps us under any realistic ceiling.
- **Encoder session exhaustion** (§6.4) — NVENC caps × split-encode × concurrent sessions must
be budgeted in one place (the admission check), or a second display can silently break an
unrelated session's encode. Split-encode is disabled for multi-display sessions by design.
- **Per-display input mapping** — each Linux injector (libei, wlr, gamescope EIS) binds
absolute coordinates differently; the §6B display-index routing is per-injector work with
per-backend validation, not one generic patch.
- **Client-side multi-window fullscreen juggling** (§6.5) — per-monitor DPI on Windows, Spaces
on macOS, pointer capture across our own windows; the reason clients stage GTK/Windows first.
- **Idle kept displays burn resources** — a kept gamescope keeps the game rendering (GPU) at
full rate; a kept KWin output keeps compositing; every §6B display encodes at full rate.
Document; a later refinement could drop a kept session's refresh, out of scope here.
- **Security posture** — keep-alive keeps a user session composited/running unattended;
nothing is unlocked that wasn't, and admission still rides pairing. `steal` on `--open`
hosts is the one sharp edge → docs recommend `reject` there (§5.3). The mgmt endpoints are
bearer-only; `local/summary` exposes counts only. §6B's extra UDP flows reuse the hardened
core `Session` unchanged (per-flow salts derived, never reused) — no new crypto surface.
- **Mutter identity** — blocked on GNOME API surface; re-check per GNOME release.
+2 -2
View File
@@ -24,8 +24,8 @@ on the host display stack. This sidesteps two otherwise-hard blockers entirely:
displays as VRR-capable. Not fixable by us; the community IDD projects' "can we fake it" issue is
open and unanswered.
- **KWin/Mutter/wlroots virtual outputs are fixed-mode** (KWin hardcodes 60 Hz + out-of-band
`kscreen-doctor` custom modes, `vdisplay/linux/kwin.rs:101,138`; Mutter defaults 60 with the
`PUNKTFUNK_MUTTER_VIRTUAL_REFRESH` opt-in, `mutter.rs:244-258`; Sway takes one
`kscreen-doctor` custom modes, `vdisplay/linux/kwin.rs:101,138`; Mutter pins the client's exact
WxH@Hz via `RecordVirtual`'s custom modes for >60 Hz, `mutter.rs`; Sway takes one
`--custom WxH@Hz`, `wlroots.rs:93`).
What a true-VRR virtual display *would* add is confined to the source end, exactly two residuals:
+44 -8
View File
@@ -34,12 +34,10 @@ curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# Add the repo (append to /etc/pacman.conf). No SigLevel line needed — pacman's default
# verifies signed packages against the key you just trusted.
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
[punktfunk]
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
EOF
# verifies signed packages against the key you just trusted. (printf, not a heredoc, so this
# works in fish too — CachyOS's default shell has no `<<EOF` support.)
printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' \
| sudo tee -a /etc/pacman.conf >/dev/null
```
> **Stable vs canary.** `[punktfunk]` is the **stable** channel — it moves only when a `vX.Y.Z`
@@ -54,7 +52,7 @@ sudo pacman -S punktfunk-web # optional: the browser management console (
sudo usermod -aG input "$USER" # /dev/uinput access for virtual gamepads (re-login to apply)
```
`punktfunk-client` (the GTK4 couch/Deck client) is in the same repo if this box is also a client.
`punktfunk-client` (the native GTK4 Linux client) is in the same repo if this box is also a client.
The host package ships the systemd **user** units, the udev rule, the UDP socket-buffer sysctl
tuning, and example configs. Updates later are just `sudo pacman -Syu`.
@@ -108,7 +106,45 @@ sed -n 's/^PUNKTFUNK_UI_PASSWORD=//p' ~/.config/punktfunk/web-password
To set your own, edit that file and `systemctl --user restart punktfunk-web`. Forgot it? See
[Forgot your Password?](/docs/forgot-password).
## 5. Connect a client
## 5. Open the firewall (if you have one)
**Stock Arch ships no firewall** — every port is already open, so you can skip this. But **CachyOS
enables `ufw` by default** (firewalld is not installed), and some other spins (e.g. EndeavourOS)
enable **`firewalld`** — an Arch package never opens ports for you, so on those the host is
unreachable until you allow it.
The `punktfunk-host` package installs openers for **both**, so it's a one-liner whichever you run:
```sh
# ufw — CachyOS (and Ubuntu, once you enable ufw):
sudo ufw allow punktfunk-native # the secure native host (the default)
sudo ufw allow punktfunk-gamestream # …also this if you run `serve --gamestream` (Moonlight)
# firewalld — Fedora-like spins (EndeavourOS, …):
sudo firewall-cmd --reload # load the installed definition
sudo firewall-cmd --permanent --add-service=punktfunk-native
sudo firewall-cmd --reload
```
`punktfunk-native` opens the QUIC control port (UDP 9777) + mDNS discovery; add
`punktfunk-gamestream` as well if you run `serve --gamestream` (the fixed Moonlight ports + mDNS).
The media **data plane** uses an *ephemeral* UDP port that the client opens with a hole-punch — the
host streams back out through the path the client opened, so there's **nothing fixed to open** as
long as the firewall allows outbound UDP (the default for both ufw and firewalld).
Enabled the **web console** (`punktfunk-web`, above) and want to reach it from your phone or another
machine? It's not opened by the streaming rules — open its port too, the same one-liner way:
```sh
sudo ufw allow punktfunk-web # ufw
sudo firewall-cmd --permanent --add-service=punktfunk-web && sudo firewall-cmd --reload # firewalld
```
That opens **TCP 47992** (HTTPS, login-gated). The mgmt API (47990) stays loopback-only and is never
opened. Full port lists (`nftables`, explicit ports) are in
[`packaging/arch/README.md`](https://git.unom.io/unom/punktfunk/src/branch/main/packaging/arch/README.md#firewall).
## 6. Connect a client
From any [client](/docs/clients), `--discover` finds the host on the LAN. On first connect, complete
the **PIN pairing** — arm it from the host's web console, which displays a 4-digit PIN to type into
+8 -3
View File
@@ -62,11 +62,16 @@ picture.
## Compositor-specific (Linux)
> **Managing virtual displays** — keep-alive after disconnect, exclusive vs. extend, and (on
> Windows/KDE) persistent per-client scaling — now has its own settings surface in the web console
> and `display-settings.json`. See [Virtual displays](/docs/virtual-displays). The two
> `*_VIRTUAL_PRIMARY` knobs and `PUNKTFUNK_MONITOR_LINGER_MS` below still work but are superseded by
> it (a settings file wins over them).
| Setting | Values | Meaning |
|---|---|---|
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` | `1` | Make the streamed per-session output the sole desktop so plasmashell + windows render on it (not on the headless bootstrap output). Set by the KDE appliance `host.env`. Superseded by the console's **Topology** setting. |
| `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | `1` | GNOME/Mutter equivalent of the above. |
| `PUNKTFUNK_MUTTER_VIRTUAL_REFRESH` | `1` | Pin the client's exact WxH**@Hz** via `RecordVirtual`'s custom modes (needed for >60 Hz on Mutter). |
## Video quality
@@ -99,7 +104,7 @@ picture.
|---|---|---|
| `PUNKTFUNK_VDISPLAY` | `pf` | Virtual-display backend. The bundled pf-vdisplay IddCx driver is the only backend now — informational; leave as `pf`. |
| `PUNKTFUNK_SECURE_DDA` | `1` | Capture the secure desktop (UAC / lock / login) so the stream survives those transitions. |
| `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. |
| `PUNKTFUNK_MONITOR_LINGER_MS` | ms (default `10000`) | Defer tearing a per-client virtual display down after disconnect. A reconnect inside the window preempts it and creates a fresh one (a reused IddCx swap-chain is dead); the stable per-client monitor id keeps Windows' saved display config applying either way. Superseded by the console's **Keep alive** setting — see [Virtual displays](/docs/virtual-displays). |
| `PUNKTFUNK_RENDER_ADAPTER` | description substring | Multi-GPU boxes only: force the NVENC/capture GPU by adapter Description substring (e.g. `4090`). Leave unset on single-GPU machines. |
| `PUNKTFUNK_HOST_CMD` | e.g. `serve --gamestream` | The host subcommand the service launches. Default `serve --gamestream`; use `serve` for a secure native-only host. |
+1
View File
@@ -24,6 +24,7 @@
"pairing",
"---Configuration---",
"configuration",
"virtual-displays",
"host-cli",
"---Troubleshooting---",
"troubleshooting",
+46 -5
View File
@@ -10,11 +10,52 @@ description: Common problems setting up or using a punktfunk host, and how to fi
- Host and client must be on the **same network/subnet**. Discovery uses mDNS, which doesn't cross
routed subnets or most VPNs-without-multicast. As a fallback, add the host by **IP address** in your
client.
- A firewall on the host can block it. The native protocol's control plane uses UDP port **9777**. The
per-session **data plane** uses an *ephemeral* UDP port negotiated at connect time (currently
random) — for a strict firewall, open a UDP range or move the data port. GameStream/Moonlight uses
TCP **47984/47989/48010** + UDP **4799848010** + ENet UDP **47999**. Allow them on the host's
firewall.
- A firewall on the host can block it. The native protocol's **control plane** is a fixed UDP port,
**9777** — open this one. The per-session **data plane** rides a *separate, random* UDP port and
usually needs **no** firewall rule (see [Video is slow to start, or fails across
subnets](#video-is-slow-to-start-or-fails-across-subnets) for why, and the one case where opening it
helps). GameStream/Moonlight (only with `--gamestream`) uses TCP **47984/47989/48010** + UDP
**4799848010** (video/FEC 47998, ENet control 47999, audio 48000) + mDNS UDP **5353**. Allow those
on the host's firewall.
## Video is slow to start, or fails across subnets
The native **data plane** (the raw UDP that carries video, separate from the 9777 control plane) uses
a **random, per-session UDP port** — the host binds `0.0.0.0:0`, then tells the client which port it
got during the connect handshake. There is no fixed data port.
Video flows host → client, but the **client sends the first packet**: a small *hole-punch* datagram to
that port. This is deliberate. It lets the host learn the client's real (possibly NAT-translated)
source address and stream back to it, so a session can cross a NAT or a stateful inter-VLAN firewall
**without** a forwarded data port. What it means for a host firewall:
- **Same LAN, no host firewall (or the port allowed):** the punch arrives immediately and video starts
at once. Nothing to configure.
- **Same LAN, host firewall that denies inbound** (ufw/nftables/firewalld default): the punch is
dropped, so the host waits **~2.5 s**, then falls back to the address the client reported and streams
anyway — a stateful firewall admits the return traffic because the host sent first. **Net effect: it
works, but each session takes ~2.5 s longer to start.** That slow start is the symptom of a
data-plane rule you're missing.
- **Across subnets / NAT:** the same punch-then-fallback applies, as long as the host's outbound video
can reach the client (the path's stateful firewall then admits the return). If the host itself is
behind NAT reached only via a forwarded control port, the data path may not establish — this is the
case a fixed, forwardable data port would solve.
To remove the ~2.5 s fallback delay, **pin the data port** with `--data-port` (or the
`PUNKTFUNK_DATA_PORT` env in `host.env`) and open exactly that one port. The host then binds that
fixed port, skips the punch-wait, and streams straight to the client — no timeout to pay:
```sh
punktfunk-host serve --data-port 9778 # or PUNKTFUNK_DATA_PORT=9778 in host.env
sudo ufw allow 9778/udp # open exactly that one port
```
Two caveats. A fixed data port serves **one session at a time**; a second concurrent session finds it
busy and transparently falls back to a random port + hole-punch (logged). And `--data-port` streams
to the client's *reported* address, so use it only where that address is reachable — a flat LAN, or a
port-forward that doesn't remap the client's source. Leave it **off** (the default) to keep the
NAT-crossing hole-punch. On a normal single-LAN setup you can also just leave the data port closed and
accept the one-time ~2.5 s punch-timeout, or not run a host firewall on a trusted LAN at all.
## `nvidia-smi` says it can't communicate with the driver
+151
View File
@@ -0,0 +1,151 @@
---
title: Virtual displays
description: Control how punktfunk creates, keeps alive, and arranges the virtual displays it streams — presets, keep-alive, exclusive vs. extend, and persistent per-client scaling.
---
When a client connects, punktfunk creates a **virtual display** sized to exactly that client's
resolution and refresh, renders your desktop or game onto it, and streams it. This page is about the
**policy** for that display: how long it survives a disconnect, whether it takes over your physical
monitors, what happens when a second client connects, and how desktop environments remember
per-client settings like scaling.
You set this policy in the **web console** (Host → *Virtual displays*), or by editing
`~/.config/punktfunk/display-settings.json` directly (`%ProgramData%\punktfunk\display-settings.json`
on Windows). A change applies to the **next** connection — a running session keeps the display it
opened on.
> **You rarely need to touch this.** The default behavior matches how punktfunk has always worked.
> Reach for a preset when you want a specific experience — a dedicated couch/gaming box, a desktop
> you also use in person, or a multi-monitor workstation.
> **What's live today:** **keep-alive** (linger, or **forever**), **topology** (extend / primary /
> exclusive), **conflict handling**, **per-client identity + persistent scaling** (Windows *and*
> KDE/KWin), and **multi-monitor layout** (several clients as monitors of one desktop) are all
> enforced. A reconnect always resumes the kept display — even a fast one — instead of spawning a
> second. The remaining gaps are noted inline: the Linux `primary` physical-keep *effect*, Sway
> `exclusive`, and multi-display for a *single* client (that last is the next stage).
## Pick a preset
A preset is the easy way in — select one in the console and you're done. Each expands to a bundle of
the individual options documented further down.
| Preset | What it's for |
|---|---|
| **Default** | Today's behavior. A short linger absorbs reconnects, the streamed output becomes the sole desktop, and extra clients each get their own view. |
| **Gaming rig** | A dedicated couch/headless box. The game and its display survive disconnects indefinitely (keep-alive **forever**), and whoever connects takes the box over. Release it from the console when you're done. |
| **Shared desktop** | A desktop you also use in person. punktfunk never blanks your real monitors and never leaves a ghost display behind; concurrent viewers each get a view. |
| **Hot-desk** | One user at a time with fast reattach — roaming between your own devices. A second user is told the box is busy, and each device+resolution keeps its own scaling. |
| **Workstation** | The multi-monitor daily driver. Your displays come back exactly where you arranged them, with per-client identity and an exclusive desktop. |
## Options reference
Choose **Custom** in the console to set these directly.
### Keep alive
How long the virtual display survives after your last session disconnects. On a gamescope game host,
this also keeps the **game itself running** so you can reconnect straight back into it.
- **Off** — tear the display down at session end (nothing lingers).
- **A duration** (seconds) — keep it for that long; a reconnect inside the window drops you straight
back in, with no re-negotiation and no desktop reshuffle.
- **Forever** — keep it until you stop the host or **release it** from the console (Host → *Virtual
displays* → *Release*). This is the gaming-rig model.
Default: **10 seconds**. Windows has always lingered 10 s; the Linux backends previously tore down
immediately — a short linger makes reconnects smoother on both.
**A reconnect always resumes the kept display** — the host recognises your device and hands back the
same display, even if you reconnect a second or two after dropping (before it has noticed you left).
**Deliberately quitting** (closing the client, not a network drop) tears the display down at once,
skipping the linger, so you don't leave a ghost behind. How quickly a *dropped* client is noticed is
the QUIC idle timeout — 8 s by default, tunable with `PUNKTFUNK_IDLE_TIMEOUT_MS` (see
[Legacy environment knobs](#legacy-environment-knobs)) if you want kept displays freed sooner.
> **Keep-alive + Exclusive keeps your physical monitors dark after you disconnect**, until the
> linger expires or you release the display. That's intentional for a dedicated gaming box, but
> don't set a long/forever keep-alive together with Exclusive on a machine whose monitors you also
> use in person — use **Shared desktop** there instead.
### Topology
What punktfunk does with your monitor layout while it streams.
- **Extend** — add the virtual display alongside your real monitors; touch nothing else.
- **Primary** — make the virtual display your primary output; your physical monitors stay on.
- **Exclusive** — the virtual display becomes your **only** enabled output (physical monitors are
disabled, then restored when streaming ends). This is what makes the streamed surface *be* the
desktop, so panels and windows land on it.
- **Automatic** *(default)* — Exclusive on Windows and on an auto-detected KDE/GNOME desktop
("stream this desktop" means the streamed output *is* the desktop); Extend when you've pinned a
specific compositor with `PUNKTFUNK_COMPOSITOR` (a test/CI posture).
Per-backend support:
| | KWin | Mutter/GNOME | Sway/wlroots | Windows |
|---|---|---|---|---|
| Extend | ✅ | ✅ | ✅ | ✅ |
| Primary | ✅ | ✅ | ⚠️ treated as Extend | ✅ |
| Exclusive | ✅ | ✅ | ⏳ following release | ✅ |
### Conflict handling · identity · layout
- **Conflict handling** — what happens when a *different* client connects while one is already
streaming and asks for a different resolution: give it its own display (**separate**), take the
box over (**steal**), share the existing display at its current mode (**join**), or refuse it
(**reject**). On Linux, `separate` gives each client its own display on the shared desktop. On
**Windows** a second client is **rejected** (a clean "host busy") even under `separate` — two
clients can't yet share one virtual display's capture there (that's a later stage), so the live
session is protected instead. A same-client *reconnect* never conflicts — it resumes.
- **Identity** — whether each client gets a **stable display identity** so your desktop environment
remembers its settings (see [Persistent scaling](#persistent-scaling)): one shared identity, one
**per client**, or one **per client + resolution**.
- **Layout / max displays** — when several clients each become a monitor of one desktop, this places
them side by side (**auto**) or exactly where you arrange them in the console (**manual**, keyed to
each client), up to **max displays**. Arrange them under Host → *Virtual displays* once two or more
are streaming.
## Persistent scaling
Set your display **scaling** once and have it stick across reconnects. This works by giving each
client a *stable display identity*, so your desktop environment keys its per-monitor settings to it.
| Host | Supported | How |
|---|---|---|
| **Windows** | ✅ today | Connect, set scaling in Settings while streaming — Windows remembers it per client. |
| **KDE / KWin** | ✅ today | Set scaling in System Settings while streaming; KWin keys it to a stable per-client output name and reapplies it on reconnect. Validated live (150 %/125 % survive a full disconnect + reconnect). |
| **GNOME / Mutter** | ❌ | GNOME's virtual-monitor API exposes no stable identity to key config on. |
| **Sway / wlroots** | ❌ | Headless outputs can't carry a stable identity; pin scale in your sway config instead. |
## Legacy environment knobs
These `PUNKTFUNK_*` variables still work, but the console (and `display-settings.json`) supersede
them — when a settings file exists, it wins.
| Legacy knob | Now expressed as |
|---|---|
| `PUNKTFUNK_MONITOR_LINGER_MS` | **Keep alive** → duration *(Windows)* |
| `PUNKTFUNK_NO_ISOLATE` | **Topology** → Extend *(Windows)* |
| `PUNKTFUNK_KWIN_VIRTUAL_PRIMARY` / `PUNKTFUNK_MUTTER_VIRTUAL_PRIMARY` | **Topology** → Exclusive (when set) / Extend (when `0`) |
One knob has no console equivalent — it's a transport tuning, not display policy:
- **`PUNKTFUNK_IDLE_TIMEOUT_MS`** (host, default `8000`) — how long the host waits before declaring a
*dropped* client gone, which is when a kept display starts its linger (or is freed). Lower it (e.g.
`3000`) to reclaim kept displays sooner after an ungraceful drop; it's clamped to ≥1 s and its
keep-alive ping scales with it, so a live session never false-disconnects. A deliberate quit is
instant regardless. Also `--idle-timeout-ms` on `punktfunk1-host`.
## Troubleshooting
**My physical monitors stayed off after I disconnected.** You have keep-alive set together with
Exclusive topology — the display (and your isolated desktop) is being kept for the linger window.
Release it from the console (Host → *Virtual displays*), or switch to the **Shared desktop** preset
so streaming never disables your real monitors.
**The virtual output shows only my wallpaper.** Your topology is Extend, so the streamed display is
an empty extension. Use **Primary** or **Exclusive** so your desktop actually lands on it.
**KWin virtual outputs need KWin ≥ 6.5.6.** Older KWin can't create the virtual output at all —
see [requirements](/docs/requirements).
+9
View File
@@ -274,6 +274,15 @@
#define VIDEO_CAP_HOST_TIMING 8
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// QUIC application error code a punktfunk/1 client closes the control connection with on a
// **deliberate quit** (a user "stop", not a network drop). The host reads it off the connection's
// `ApplicationClosed` reason and tears the session's virtual display down immediately, skipping the
// keep-alive linger; any other close reason (idle timeout, reset, a bare code 0) still lingers so a
// reconnect can resume. Shared so host + every client agree on the code.
#define QUIT_CLOSE_CODE 81
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software**
// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
+18 -2
View File
@@ -1,7 +1,7 @@
# Maintainer: unom <noreply@anthropic.com>
#
# Arch Linux / SteamOS split package: punktfunk-host (the gaming-rig HOST, NVENC) and
# punktfunk-client (the GTK4 couch/Deck CLIENT). Mirrors the rpm subpackages
# punktfunk-client (the native GTK4/libadwaita Linux CLIENT). Mirrors the rpm subpackages
# (packaging/rpm/punktfunk.spec) and the two deb build scripts. On a Steam Deck you want
# `punktfunk-client` (it's what the Decky plugin launches); on a gaming rig, `punktfunk-host`.
#
@@ -134,13 +134,29 @@ package_punktfunk-host() {
install -Dm0644 "$R/packaging/bazzite/gamescope-headless-session" \
"$pkgdir/etc/gamescope-session-plus/sessions.d/steam"
install -Dm0644 "$R/api/openapi.json" "$pkgdir/usr/share/punktfunk/openapi.json"
# Firewall openers — NOT auto-enabled (an Arch package never touches the admin's running firewall).
# Stock Arch ships no firewall; CachyOS ships ufw; some spins (EndeavourOS) enable firewalld — so we
# install BOTH a ufw application profile and firewalld service definitions, and the one for whatever
# firewall you actually run is a one-liner. See README.md → Firewall.
# ufw: sudo ufw allow punktfunk-native (or punktfunk-gamestream)
# firewalld: sudo firewall-cmd --reload && sudo firewall-cmd --permanent --add-service=punktfunk-native && sudo firewall-cmd --reload
install -Dm0644 "$R/packaging/linux/punktfunk.ufw" \
"$pkgdir/etc/ufw/applications.d/punktfunk"
install -Dm0644 "$R/packaging/linux/punktfunk-gamestream.xml" \
"$pkgdir/usr/lib/firewalld/services/punktfunk-gamestream.xml"
install -Dm0644 "$R/packaging/linux/punktfunk-native.xml" \
"$pkgdir/usr/lib/firewalld/services/punktfunk-native.xml"
# Web console opener (TCP 47992) — only meaningful with the optional punktfunk-web package; opened
# deliberately (see README.md → Firewall). ufw's equivalent is the punktfunk-web profile above.
install -Dm0644 "$R/packaging/linux/punktfunk-web.xml" \
"$pkgdir/usr/lib/firewalld/services/punktfunk-web.xml"
install -Dm0644 "$R/LICENSE-MIT" "$pkgdir/usr/share/licenses/punktfunk-host/LICENSE-MIT"
install -Dm0644 "$R/LICENSE-APACHE" "$pkgdir/usr/share/licenses/punktfunk-host/LICENSE-APACHE"
install -Dm0644 "$R/README.md" "$pkgdir/usr/share/doc/punktfunk-host/README.md"
}
package_punktfunk-client() {
pkgdesc="Low-latency desktop/game streaming CLIENT (GTK4) — the couch/Deck side"
pkgdesc="Low-latency desktop/game streaming CLIENT — native GTK4/libadwaita Linux app"
# The GTK4/libadwaita client: SDL3 gamepads, FFmpeg (VAAPI) decode, PipeWire audio/mic.
depends=('gtk4' 'libadwaita' 'sdl3' 'ffmpeg' 'pipewire' 'wireplumber' 'pipewire-pulse'
'opus' 'libglvnd')
+69 -19
View File
@@ -1,9 +1,9 @@
# punktfunk on Arch Linux / SteamOS
Packaging for punktfunk on Arch and Arch-derived immutable distros. The `PKGBUILD` is a **split
package** producing **`punktfunk-host`** (the gaming-rig host) and **`punktfunk-client`** (the GTK4
couch/Deck client) — mirrors the rpm subpackages (`packaging/rpm/punktfunk.spec`) and the deb build
scripts. On a **Steam Deck used as a client you want `punktfunk-client`** (it's what the
package** producing **`punktfunk-host`** (the gaming-rig host) and **`punktfunk-client`** (the native
GTK4/libadwaita Linux client) — mirrors the rpm subpackages (`packaging/rpm/punktfunk.spec`) and the
deb build scripts. On a **Steam Deck used as a client you want `punktfunk-client`** (it's what the
[Decky plugin](../../clients/decky/) launches); on a gaming rig, `punktfunk-host`.
> **Steam Deck as a HOST:** don't use this PKGBUILD — SteamOS's read-only root makes `makepkg`/sysext
@@ -42,15 +42,13 @@ curl -fsS https://git.unom.io/api/packages/unom/arch/repository.key \
sudo pacman-key --lsign-key E0CA04465C99C936E0B0C6510A317015A34DDD69
# 2. Add the repo (pick ONE channel — punktfunk for releases, punktfunk-canary for main builds).
sudo tee -a /etc/pacman.conf >/dev/null <<'EOF'
[punktfunk]
Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
EOF
# printf, not a heredoc, so this works in fish too (CachyOS's default shell has no `<<EOF`).
printf '\n[punktfunk]\nServer = https://git.unom.io/api/packages/unom/arch/$repo/$arch\n' \
| sudo tee -a /etc/pacman.conf >/dev/null
# 3. Sync + install.
sudo pacman -Sy punktfunk-host # gaming rig
sudo pacman -Sy punktfunk-client # couch/Deck side
sudo pacman -Sy punktfunk-client # the native GTK4 Linux client
sudo pacman -Sy punktfunk-web # optional browser management console
```
@@ -139,11 +137,55 @@ so it's a much lighter sysext than the host.
## Firewall
If the host box runs a firewall, open the ports it listens on. The **native `punktfunk/1`** plane:
**Stock Arch ships no firewall** — every port is open by default, so there is nothing to do.
Spins that enable one **do not** get their ports opened for you: an Arch package never touches the
admin's running firewall. **CachyOS is the common case** — it ships `ufw` enabled by default (not
firewalld), so out of the box the host is unreachable until you allow it. Some other spins (e.g.
EndeavourOS) enable `firewalld` instead.
The `punktfunk-host` package ships openers for **both** — a ufw application profile
(`/etc/ufw/applications.d/punktfunk`) and firewalld service definitions
(`/usr/lib/firewalld/services/`) — so enabling is one command whichever you run:
```sh
# ufw (CachyOS, and Ubuntu once you enable ufw) — reads the profile at once, no reload needed:
sudo ufw allow punktfunk-native # the native-only host (the default)
sudo ufw allow punktfunk-gamestream # …or add this for the Moonlight/GameStream host
# firewalld (EndeavourOS and other Fedora-like spins):
sudo firewall-cmd --reload # pick up the installed def
sudo firewall-cmd --permanent --add-service=punktfunk-native
# --add-service=punktfunk-gamestream # …for the Moonlight host
sudo firewall-cmd --reload
```
`punktfunk-gamestream` opens the fixed Moonlight ports + mDNS; `punktfunk-native` opens the QUIC
control port (UDP 9777) + mDNS. Enable both if the host runs `serve --gamestream` (which serves
both planes). The **data plane is an *ephemeral* UDP port** the client opens with a hole-punch, so
there is no fixed data port in either service — the host streams back out through the path the
client opened, which any firewall that allows outbound UDP (the default) passes. The mgmt REST API
(TCP 47990) binds to loopback by default — leave it closed unless you move it off loopback with
`--mgmt-bind IP:PORT` (which then requires `--mgmt-token`).
If you installed the **web console** (`punktfunk-web`) and want it reachable from another device,
open its port with the matching one-liner — `sudo ufw allow punktfunk-web` or `sudo firewall-cmd
--permanent --add-service=punktfunk-web && sudo firewall-cmd --reload` — which opens **TCP 47992**
(HTTPS, login-gated). The mgmt API (47990) stays loopback-only.
Prefer explicit rules (or a firewall the shipped profiles don't cover)? Open the ports directly.
The **native `punktfunk/1`** plane:
- **QUIC control plane: UDP 9777** (`serve --native-port N` to change).
- **Data plane: an *ephemeral* UDP port** — negotiated per session, so there is no fixed port to
open. For a restrictive firewall you'd need to allow a UDP range (the repo does not pin one).
- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and
tells the client which port it got. Video flows host → client, but the **client sends the first
packet** (a hole-punch), so the host learns the client's real source and streams back — this
traverses NAT / inter-VLAN with no forwarded port. **You normally don't open it:** if a deny-inbound
firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a
stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay,
pin it with **`serve --data-port <PORT>`** (or `PUNKTFUNK_DATA_PORT`): the host binds that fixed
port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one
session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the
client's reported address to be reachable (flat LAN / a non-remapping port-forward).
And the **GameStream / Moonlight** ports (fixed) — only needed if you run the host with
`serve --gamestream` (opt-in, trusted LAN only); bare `serve` is native-only and doesn't open these:
@@ -159,14 +201,16 @@ And the **GameStream / Moonlight** ports (fixed) — only needed if you run the
The mgmt API (TCP 47990) binds to loopback by default — leave it closed unless you move it off
loopback with `--mgmt-bind IP:PORT` (which then requires `--mgmt-token`).
With `ufw`:
With `ufw` (explicit ports, instead of the shipped `punktfunk-native`/`punktfunk-gamestream` profile):
```sh
sudo ufw allow 9777/udp # punktfunk/1 control plane
sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp
sudo ufw allow 47998:48010/udp
sudo ufw allow 5353/udp
# plus the ephemeral punktfunk/1 data port — open a UDP range you reserve for it.
sudo ufw allow 47998,47999,48000/udp # GameStream video/control/audio
sudo ufw allow 5353/udp # mDNS discovery
# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches
# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port
# 9778` and `ufw allow 9778/udp`.
```
With raw `nftables` (add to your `inet filter input` chain):
@@ -174,14 +218,20 @@ With raw `nftables` (add to your `inet filter input` chain):
```
udp dport 9777 accept # punktfunk/1 control plane
tcp dport { 47984, 47989, 48010 } accept
udp dport { 47998-48010, 5353 } accept
# plus the ephemeral punktfunk/1 data port (a reserved UDP range).
udp dport { 47998-48000, 5353 } accept # GameStream video/control/audio + mDNS
# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s
# fallback). Pin it with `serve --data-port <PORT>` to open exactly one instead.
```
## Files
- `PKGBUILD` — split package: `punktfunk-host` + `punktfunk-client` (builds the working tree via
`PF_SRCDIR`, or a git tag for AUR).
- `punktfunk-host.install` / `punktfunk-client.install` — pacman scriptlets (udev reload + sysctl +
first-run hint), mirror the RPM `%post` / deb postinst.
first-run hint, incl. the ufw/firewalld enable command for whichever is present), mirror the RPM
`%post` / deb postinst.
- The firewall openers are shared across all Linux packaging and live in [`../linux/`](../linux/):
the ufw application profile (`punktfunk.ufw``/etc/ufw/applications.d/punktfunk`) and the
firewalld service definitions (`punktfunk-native.xml` / `punktfunk-gamestream.xml` /
`punktfunk-web.xml``/usr/lib/firewalld/services/`). None auto-enabled; see Firewall above.
- `build-sysext.sh` — wraps either built `.pkg.tar.zst` into a `systemd-sysext` `.raw` for SteamOS
(derives the name from the package, so it works for host or client).
+21
View File
@@ -17,6 +17,27 @@ punktfunk-host installed.
NOTE: encode is NVENC-only. Install 'nvidia-utils' on an NVIDIA host. An AMD Steam Deck is NOT
yet supported — it needs a VAAPI (hevc_vaapi) encoder backend (see packaging/arch/README.md).
MSG
# Firewall: stock Arch ships none (ports already open); CachyOS ships ufw; some spins (EndeavourOS)
# enable firewalld. We install a ufw app profile AND firewalld service definitions but never touch
# the running firewall — just point the way for whichever is active.
if command -v ufw >/dev/null 2>&1; then
cat <<'MSG'
4. ufw is installed — open the streaming ports once (native-only host shown; add
'punktfunk-gamestream' as well for Moonlight compat):
sudo ufw allow punktfunk-native
MSG
fi
if command -v firewall-cmd >/dev/null 2>&1; then
cat <<'MSG'
4. firewalld is active — open the streaming ports once (native-only host shown; add
'punktfunk-gamestream' as well for Moonlight compat):
sudo firewall-cmd --reload # load the new service def
sudo firewall-cmd --permanent --add-service=punktfunk-native
sudo firewall-cmd --reload
MSG
fi
}
post_upgrade() {
+26 -8
View File
@@ -321,10 +321,23 @@ journalctl --user -u punktfunk-host -f
## 6. Firewall
> ⚠️ **There is no firewall script or firewall doc in the repo.** The ports below are derived
> directly from the code constants (`crates/punktfunk-host/src/gamestream/mod.rs`, `mgmt.rs`) and
> the GameStream-host port-map (`design/gamestream-host-plan.md`). Treat the `firewall-cmd` lines as recommended-but-verified,
> not a checked-in script.
Bazzite runs **firewalld**, so the ports must be opened. The `punktfunk-host` package installs
firewalld **service definitions** (`/usr/lib/firewalld/services/punktfunk-gamestream.xml` and
`punktfunk-native.xml`), so enabling is one command — reload first so firewalld picks up the
definition, add the service, reload to apply:
```sh
sudo firewall-cmd --reload
sudo firewall-cmd --permanent --add-service=punktfunk-gamestream # Moonlight/GameStream host
# --add-service=punktfunk-native # …or the native-only host
sudo firewall-cmd --reload
```
`punktfunk-gamestream` opens the fixed Moonlight ports + mDNS; `punktfunk-native` opens the QUIC
control port (UDP 9777) + mDNS. Enable both if the host runs `serve --gamestream` (both planes). The
per-port breakdown below is for reference (or for opening ports by hand); the ports are the code
constants (`crates/punktfunk-host/src/gamestream/mod.rs`, `mgmt.rs`) and the GameStream-host port-map
(`design/gamestream-host-plan.md`).
**GameStream / Moonlight ports** (fixed; Moonlight derives them from the HTTP base). These only apply
when the host runs `serve --gamestream` (the bundled unit's default); on a bare-`serve` native-only
@@ -344,7 +357,7 @@ host you don't open them:
default**, so you do **not** open it in the firewall unless you deliberately move it off loopback
with `--mgmt-bind IP:PORT` (which also requires `--mgmt-token`). Leave it closed for a normal setup.
Open the GameStream ports with `firewalld` (Bazzite uses firewalld):
To open the GameStream ports by hand instead of the service (equivalent):
```sh
sudo firewall-cmd --permanent --add-port=47984/tcp \
@@ -361,9 +374,14 @@ sudo firewall-cmd --reload
default unit):
- **QUIC control plane: UDP 9777** (default `--port`; change with `--port N`).
- **Data plane: an *ephemeral* UDP port**`punktfunk1-host` binds `0.0.0.0:0` and tells the client which
port it got, so there is **no fixed data port to open**. For a restrictive firewall you'd need to
allow the ephemeral UDP range; the repo does not pin one.
- **Data plane: a separate UDP port**by default *random* (`0.0.0.0:0`), so there is **no fixed
port to open**. Video flows host → client, but the client sends the first packet (a hole-punch): if
firewalld drops it, the host waits ~2.5 s and falls back to the client-reported address and streams
anyway, so you normally **leave the data port closed**. To skip that ~2.5 s fallback, pin it with
`serve --data-port <PORT>` (or `PUNKTFUNK_DATA_PORT`) and open exactly that one port with
`firewall-cmd --add-port=<PORT>/udp`. A fixed port serves one session at a time (concurrent ones
fall back to random + hole-punch) and streams to the client's reported address (flat LAN /
non-remapping forward only).
```sh
# Only if you run `punktfunk1-host`:
+41 -9
View File
@@ -9,7 +9,7 @@ to a canary build — see [Release Channels](https://punktfunk.unom.io/docs/chan
below subscribes to `stable`; swap `stable``canary` for the latest main builds.
The same workflow also publishes **`punktfunk-web`** (the browser management console — pairing +
status) and **`punktfunk-client`** (the GTK4 couch/Deck client). `punktfunk-host` **Recommends**
status) and **`punktfunk-client`** (the native GTK4/libadwaita Linux client). `punktfunk-host` **Recommends**
`punktfunk-web`, so a default `apt install punktfunk-host` pulls the console too (alongside the
udev/sysctl bits) unless you've disabled weak deps; `punktfunk-client` is independent — install it
on the box you stream *to*. (`punktfunk-probe` is the headless reference/test tool, not packaged
@@ -52,11 +52,40 @@ journalctl --user -u punktfunk-web-init | sed -n 's/.*password generated: //p'
## Firewall
Open the ports the host listens on. The **native `punktfunk/1`** plane:
**Debian ships no firewall and Ubuntu's `ufw` is installed-but-inactive by default**, so out of the
box there is nothing to open. If you turn one on, the `punktfunk-host` package ships a one-liner
opener for both **ufw** and **firewalld** (neither auto-enabled):
```sh
# ufw (Ubuntu) — profile at /etc/ufw/applications.d/punktfunk, read at once (no reload):
sudo ufw allow punktfunk-native # the default native host
sudo ufw allow punktfunk-gamestream # …add for Moonlight compat
# firewalld — service definitions at /usr/lib/firewalld/services/:
sudo firewall-cmd --reload # load the installed definition
sudo firewall-cmd --permanent --add-service=punktfunk-native
# --add-service=punktfunk-gamestream # …add for Moonlight compat
sudo firewall-cmd --reload
```
If you installed the **web console** (`punktfunk-web`) and want it reachable from another device,
open its port with the matching one-liner — `sudo ufw allow punktfunk-web` or `sudo firewall-cmd
--permanent --add-service=punktfunk-web && sudo firewall-cmd --reload` — which opens **TCP 47992**
(HTTPS, login-gated). The mgmt API (47990) stays loopback-only.
Prefer explicit rules? Open the ports directly. The **native `punktfunk/1`** plane:
- **QUIC control plane: UDP 9777** (`serve --native-port N` to change).
- **Data plane: an *ephemeral* UDP port** — negotiated per session, so there is no fixed port to
open. For a restrictive firewall you'd need to allow a UDP range (the repo does not pin one).
- **Data plane: a separate UDP port.** By default it's *random* — the host binds `0.0.0.0:0` and
tells the client which port it got. Video flows host → client, but the **client sends the first
packet** (a hole-punch), so the host learns the client's real source and streams back — this
traverses NAT / inter-VLAN with no forwarded port. **You normally don't open it:** if a deny-inbound
firewall drops the punch, the host waits ~2.5 s and falls back to the client-reported address, and a
stateful firewall then admits the return (it just adds ~2.5 s to session start). To skip that delay,
pin it with **`serve --data-port <PORT>`** (or `PUNKTFUNK_DATA_PORT`): the host binds that fixed
port and streams direct (no punch-wait) — open exactly that one port. A fixed port serves one
session at a time (concurrent ones fall back to random + hole-punch), and direct mode needs the
client's reported address to be reachable (flat LAN / a non-remapping port-forward).
And the **GameStream / Moonlight** ports (fixed) — only needed if you run the host with
`serve --gamestream` (opt-in, trusted LAN only); bare `serve` is native-only and doesn't open these:
@@ -72,14 +101,16 @@ And the **GameStream / Moonlight** ports (fixed) — only needed if you run the
The mgmt API (TCP 47990) binds to loopback by default — leave it closed unless you move it off
loopback with `--mgmt-bind IP:PORT` (which then requires `--mgmt-token`).
With `ufw`:
With `ufw` (explicit ports, instead of the shipped profile):
```sh
sudo ufw allow 9777/udp # punktfunk/1 control plane
sudo ufw allow 47984/tcp && sudo ufw allow 47989/tcp && sudo ufw allow 48010/tcp
sudo ufw allow 47998:48010/udp
sudo ufw allow 5353/udp
# plus the ephemeral punktfunk/1 data port — open a UDP range you reserve for it.
sudo ufw allow 47998,47999,48000/udp # GameStream video/control/audio
sudo ufw allow 5353/udp # mDNS discovery
# The punktfunk/1 data plane uses a random UDP port; leave it closed on a LAN — the host hole-punches
# and falls back (~2.5s at session start if firewalled). To skip that, pin it: `serve --data-port
# 9778` and `ufw allow 9778/udp`.
```
With raw `nftables` (add to your `inet filter input` chain):
@@ -88,7 +119,8 @@ With raw `nftables` (add to your `inet filter input` chain):
udp dport 9777 accept # punktfunk/1 control plane
tcp dport { 47984, 47989, 48010 } accept
udp dport { 47998-48010, 5353 } accept
# plus the ephemeral punktfunk/1 data port (a reserved UDP range).
# The punktfunk/1 data plane is a random UDP port — normally left closed (hole-punch + ~2.5s
# fallback). Pin it with `serve --data-port <PORT>` to open exactly one instead.
```
## Updates
+22
View File
@@ -80,6 +80,19 @@ install -Dm0644 scripts/host.env.example "$SHAREDIR/host.env.example"
install -Dm0644 packaging/bazzite/host.env "$SHAREDIR/host.env.bazzite"
install -Dm0644 packaging/kde/host.env "$SHAREDIR/host.env.kde"
install -Dm0644 api/openapi.json "$SHAREDIR/openapi.json"
# Firewall openers (shared across all Linux packaging), NOT auto-enabled — the postinst prints the
# enable command for whichever firewall is present. Debian ships none and Ubuntu's ufw is
# installed-but-inactive, so these are a no-op until the admin turns a firewall on.
install -Dm0644 packaging/linux/punktfunk.ufw \
"$STAGE/etc/ufw/applications.d/punktfunk"
install -Dm0644 packaging/linux/punktfunk-gamestream.xml \
"$STAGE/usr/lib/firewalld/services/punktfunk-gamestream.xml"
install -Dm0644 packaging/linux/punktfunk-native.xml \
"$STAGE/usr/lib/firewalld/services/punktfunk-native.xml"
# Web console opener (TCP 47992) — only meaningful with the optional punktfunk-web package; opened
# deliberately (see README.md → Firewall). ufw's equivalent is the punktfunk-web profile above.
install -Dm0644 packaging/linux/punktfunk-web.xml \
"$STAGE/usr/lib/firewalld/services/punktfunk-web.xml"
install -Dm0644 LICENSE-MIT "$DOCDIR/LICENSE-MIT"
install -Dm0644 LICENSE-APACHE "$DOCDIR/LICENSE-APACHE"
install -Dm0644 README.md "$DOCDIR/README.md"
@@ -186,6 +199,15 @@ if [ "$1" = "configure" ]; then
echo " sudo usermod -aG input \"\$USER\" # then re-login"
echo "Config: mkdir -p ~/.config/punktfunk && cp /usr/share/punktfunk-host/host.env.example ~/.config/punktfunk/host.env"
echo "Enable: systemctl --user enable --now punktfunk-host"
# Debian ships no active firewall and Ubuntu's ufw is inactive by default; hint whichever is present.
if command -v ufw >/dev/null 2>&1; then
echo "Firewall (ufw detected): sudo ufw allow punktfunk-native (or punktfunk-gamestream for Moonlight)"
fi
if command -v firewall-cmd >/dev/null 2>&1; then
echo "Firewall (firewalld detected): sudo firewall-cmd --reload &&"
echo " sudo firewall-cmd --permanent --add-service=punktfunk-native && sudo firewall-cmd --reload"
echo " (use punktfunk-gamestream for the Moonlight-compat host)"
fi
fi
exit 0
EOF

Some files were not shown because too many files have changed in this diff Show More