73 Commits

Author SHA1 Message Date
enricobuehler 8ef320662b docs: zero-copy EGL/CUDA capture-crash hardening handoff
Describes a reproduced host SIGSEGV in cuGraphicsMapResources (inside
libnvidia-eglcore), reached via zerocopy::cuda / zerocopy::egl on the tiled
EGL/GL->CUDA capture path, when the KWin dmabuf is invalidated mid-map (observed
on .181 during a Game->Desktop switch under zero-copy, with the compositor itself
crashing). Pre-existing capture-layer issue, not the gamemode work. Issue
description + root cause + solution-space considerations only -- the next agent
plans the implementation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 06:26:22 +00:00
enricobuehler 04309d0ad9 feat(host): game-mode integration + dedicated game sessions
Implements design/gamemode-and-dedicated-sessions.md (Parts A1-A5 + B0-B2):
reconciles the merged display-management registry with session-mobile
Bazzite/SteamOS hosts and adds a per-launch dedicated gamescope mode.

- A1 DisplayOwnership {Owned,External,SessionManaged} + poolable_now(): the
  registry pools only what it owns, so gamescope managed/attach outputs are no
  longer double-owned by the registry AND the gamescope restore worker (fixes
  the game-mode-reconnect stale-node wedge).
- A2 validated reuse: (backend,mode,launch,epoch) reuse key + kept_display_alive
  liveness probe + reused_gen/mark_failed on a reused-display first-frame failure.
- A3 policy-driven managed restore (keep_alive replaces the hardcoded 5s debounce;
  forever = held = gaming-rig truthful) + crash-restore persist + SIGKILL teardown
  (kill_unit, applied to our transient unit AND the autologin stop -- validated
  live on .181 to avoid the F44 GPU-context leak).
- A4 session epoch: observe_session_instance bumps the epoch + invalidate_backend
  on a desktop-compositor instance change; gamescope spawns are exempt.
- A5 per-spawn log + PID-scoped gamescope node discovery.
- B0 game_session {auto,dedicated} policy (top-level, preset-orthogonal) +
  pick_gamescope_mode dedicated_launch + steam -silent command shaping.
- B1 free the autologin Steam before a dedicated Steam spawn (single-instance).
- B2 game-exit -> APP_EXITED_CLOSE_CODE (0x52) clean session end.

Adversarially reviewed (11 findings fixed). Validated on glass (.181 Bazzite F44,
RTX 4090): dedicated spawn streams a real game smoothly; keep-alive reuse; the
SIGKILL fix avoids the F44 vkCreateDevice leak. Workspace green
(build / test --workspace / clippy -D warnings / fmt), OpenAPI + C header
regenerated, web console tsc + vite build green. clients/probe: bump the
no-video timeout 8s->45s for gamescope cold starts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 06:26:22 +00:00
enricobuehler d76a42e0e9 docs(security): add 2026-07-05 whole-project posture audit
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
audit / bun-audit (push) Has been cancelled
audit / cargo-audit (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
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
windows-host / package (push) Has been cancelled
Assessment of the overall security architecture, the state of the prior
reviews' remediations, and the process/supply-chain controls. Flags the
two web-console residuals (no brute-force throttle; cookie seal key from
the login password) that the accompanying web-hardening change fixes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 07:28:51 +02:00
enricobuehler 5a51b0d8e7 ci: SHA-pin secret-bearing third-party actions + audit the web dep tree
- Pin android-actions/setup-android, appleboy/scp-action, and
  appleboy/ssh-action to commit SHAs (version kept in a trailing comment).
  These run in jobs holding the Android signing keystore, Play
  service-account, and deploy SSH key, so a moved tag on a third-party
  action could exfiltrate them.
- Add a bun-audit job to audit.yml over web/bun.lock — the console holds
  the login gate, session sealing, and mgmt token, so its deps matter too
  — and trigger the workflow on web/bun.lock changes alongside Cargo.lock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 07:28:45 +02:00
enricobuehler bda5556d37 feat(web): harden login gate — throttle, scoped TLS, token-derived seal key
Remediates the two web-console residuals from the 2026-07-05 posture audit:

- Brute-force throttle (loginThrottle.ts): per-IP exponential backoff
  after 5 free attempts, plus a global floor for spread-out floods, keyed
  on the socket peer IP (not spoofable X-Forwarded-For) with a size-capped
  map. The constant-time compare already stopped the timing leak; this
  bounds guess *volume* against a by-design LAN-exposed console.
- Session seal key now derives from the high-entropy mgmt token instead of
  the low-entropy login password, so a captured cookie is no longer an
  offline password oracle. Falls back to the password only when no token
  is configured (dev/local). Rotating the token now invalidates sessions.
- Replace the process-wide NODE_TLS_REJECT_UNAUTHORIZED=0 with per-request
  Bun TLS scoped to the loopback proxy hop; a non-loopback mgmt URL now
  verifies normally. Dropped the env var from the systemd unit, Steam Deck
  installer, Windows run scripts, env examples, and web README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 07:28:39 +02:00
enricobuehler ab56536842 docs(readme): Windows host capture is IDD-push, not DXGI/WGC
The DXGI Desktop Duplication + WGC relay paths were removed; sealed
IDD-push (finished frames pushed straight into the host's own IddCx
driver, no screen-scraping) is now the sole Windows capture path. Fix the
stale "DXGI/WGC capture" claims in the root and punktfunk-host READMEs,
which also contradicted the push-based IDD description already present in
the root README.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 07:28:29 +02:00
enricobuehler 27c53a4b53 perf(android): low-latency decode overhaul — vendor keys, async loop, system tuning
android / android (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 9m21s
Close the latency gap on the Android client with per-SoC decoder tuning, an
event-driven decode loop, and full system integration.

- Decoder selection: rank MediaCodecList decoders in Kotlin (hardware/vendor
  preferred, software avoided, FEATURE_LowLatency probed) and create the chosen
  one by name. Per-SoC low-latency keys gated on the codec-name prefix: Qualcomm
  picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon;
  MediaTek vdec-lowlatency set unconditionally. operating-rate = MAX (Qualcomm)
  vs priority = 0 (else) are mutually exclusive. NVIDIA/Rockchip/Realtek have no
  vendor key — covered by ranking + the standard low-latency key.

- Async decode loop: AMediaCodec async-notify replaces the poll loop, presenting a
  decoded frame the instant it is ready instead of waiting out a poll interval.
  Behind USE_ASYNC_DECODE with the synchronous loop kept for A/B during bring-up.

- System integration: Wi-Fi FULL_LOW_LATENCY lock and HDMI ALLM
  (setPreferMinimalPostProcessing) for the stream's lifetime; game_mode_config.xml
  opting out of OEM downscaling / FPS overrides.

- Pipeline: boost the data-plane pump + audio thread priorities, AAudio usage=Game,
  DSCP marking on by default on Android, ADPF setPreferPowerEfficiency(false),
  and setFrameRateWithChangeStrategy(ALWAYS) to force the HDMI mode switch on TV.

- lowLatencyMode master toggle (default on) as the escape hatch; the stats HUD now
  shows the resolved decoder name.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-06 07:25:16 +02:00
enricobuehler 69fcb6e0b1 docs: restructure host setup by distro, configuration by compositor
apple / swift (push) Successful in 1m8s
apple / screenshots (push) Successful in 5m33s
android / android (push) Successful in 4m43s
arch / build-publish (push) Successful in 5m38s
ci / web (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m17s
ci / rust (push) Successful in 4m48s
ci / bench (push) Successful in 5m7s
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 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
deb / build-publish (push) Successful in 4m29s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m24s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m3s
Split the docs' single distro×desktop axis (ubuntu-gnome / ubuntu-kde / fedora-kde) into two,
which deduplicates the shared mechanics and scales to distros that run several desktops (Arch):

- Install the host — per distro/OS (ubuntu, fedora, arch, bazzite, steamos-host, windows-host):
  GPU driver + package + input group, then a canonical "Configure your desktop" funnel.
- Configure your desktop — per compositor (kde, gnome, gamescope, sway): host.env, compositor
  quirks, the headless session, and starting the host.

New shared web-console page (enable · login password · arm pairing) removes the console/password
block that was copy-pasted across all seven host pages. Merged ubuntu-gnome + ubuntu-kde into
ubuntu; renamed fedora-kde to fedora; kept bazzite and steamos-host as dedicated appliance guides
(trimmed of duplication). Moved the KWin headless session, the GNOME EGL/lock traps, and the
gamescope attach/managed model out of the distro pages onto their compositor pages.

Fixed while restructuring: distro-specific paths on kde (kde-desktop-setup.sh is Fedora/Bazzite-only;
the .deb ships host.env.kde under /usr/share/punktfunk-host), the interactive "start the host" step
that was lost in the merge, sway over-claiming Hyprland, and a pre-existing broken anchor in
how-it-works.

Removal of the three old pages was captured by the preceding commit 8ebb614 (a concurrent commit
swept up the staged git-rm); the net docs tree is correct. Fumadocs build + internal link/anchor
check green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 21:04:31 +00:00
enricobuehler 8ebb61400c fix(web): clearer topology/identity copy, capped description width, mobile More-nav, preset spacing
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 5m18s
windows-host / package (push) Successful in 7m38s
android / android (push) Successful in 12m11s
arch / build-publish (push) Successful in 9m7s
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m18s
ci / rust (push) Successful in 4m47s
ci / bench (push) Successful in 5m3s
decky / build-publish (push) Successful in 19s
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 32s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish (push) Successful in 4m31s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 10m48s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 10m28s
docker / deploy-docs (push) Successful in 20s
Console polish on the Virtual displays card + shell:
- Topology help now leads with the streamed display's role (Extend/Primary/Exclusive) instead of
  the confusing physical-monitor-only framing; notes the headless case. Identity help spells out the
  actual behavior (stable per-client identity → the desktop reapplies that client's scaling/resolution
  on reconnect) + what Shared / Per-client / Per-client+resolution each do.
- Cap description/help width at max-w-prose so long help text isn't a full-viewport line on large screens.
- Mobile bottom nav: 8 flat tabs were too cramped → 4 pinned tabs + a "More" tab whose sheet holds the
  rest (Performance/Logs/Pairing/Settings), "More" highlighted when the active route is in the overflow.
- More breathing room under the "Preset" heading.

web tsc + biome + vite build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 20:40:35 +00:00
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
enricobuehler 202f40fd4e chore(release): bump workspace version to 0.7.4
apple / swift (push) Successful in 1m7s
audit / cargo-audit (push) Successful in 1m33s
ci / web (push) Successful in 46s
ci / docs-site (push) Successful in 58s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 51s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 55s
ci / rust (push) Successful in 7m8s
android-screenshots / screenshots (push) Successful in 46s
ci / bench (push) Successful in 4m49s
android / android (push) Successful in 3m20s
release / apple (push) Successful in 9m30s
windows-host / package (push) Successful in 7m13s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m13s
arch / build-publish (push) Successful in 8m8s
apple / screenshots (push) Successful in 5m47s
deb / build-publish (push) Successful in 2m56s
decky / build-publish (push) Successful in 14s
flatpak / build-publish (push) Successful in 4m53s
linux-client-screenshots / screenshots (push) Successful in 1m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 9m51s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m36s
docker / deploy-docs (push) Successful in 6s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 5s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
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 4s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 4s
web-screenshots / screenshots (push) Successful in 2m40s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:46:34 +00:00
enricobuehler 8f90563ffd docs: dedicated Arch Linux host+client guide
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
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
Every other distro has a full Host Setup page; Arch only had table rows. Add
docs/arch.md (signed pacman binary repo: key import + repo + install, GPU
prereqs, service/linger, web console, client, PKGBUILD appendix), slot it into
the nav after fedora-kde, and point the install/client tables at it. Update the
client-install rows from 'from the PKGBUILD' to the binary repo now that it exists.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:37:01 +00:00
enricobuehler 2e6b822fd6 docs(ci/arch): correct the header's pacman setup (key import, not TrustAll) + note the trust root
android / android (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
apple / swift (push) Has been cancelled
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:19:28 +00:00
enricobuehler f7c5314b5e fix(packaging/arch): correct pacman setup — import the registry key, cache cargo git
apple / swift (push) Successful in 1m10s
android / android (push) Successful in 3m18s
apple / screenshots (push) Has been cancelled
arch / build-publish (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
The Gitea Arch registry signs its DB + packages, so 'SigLevel = Optional TrustAll' fails
non-interactively (pacman still needs the key to verify). Document the one-time
pacman-key import instead; install is then signature-validated under pacman's default
SigLevel (verified end-to-end: clean archlinux container -> repo sync -> install,
'Validated By: Signature').

Also cache /usr/local/cargo/git in arch.yml: the workspace pulls clients/windows'
git-pinned windows-reactor/windows deps to resolve, cloning windows-rs (huge) every run
otherwise — same registry+git cache deb.yml uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:16:24 +00:00
enricobuehler d6669fc3fb fix(ci/arch): create CARGO_HOME before chown — actions/cache doesn't on a miss
android / android (push) Has been cancelled
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
ci / rust (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
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
arch / build-publish (push) Successful in 7m31s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:03:46 +00:00
enricobuehler e292084225 fix(ci/arch): install nodejs before actions/checkout — act_runner doesn't inject node
apple / screenshots (push) Has been cancelled
apple / swift (push) Has been cancelled
ci / rust (push) Has been cancelled
ci / web (push) Has been cancelled
ci / docs-site (push) Has been cancelled
ci / bench (push) Has been cancelled
android / android (push) Has been cancelled
deb / build-publish (push) Has been cancelled
decky / build-publish (push) Has been cancelled
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Has been cancelled
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Has been cancelled
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Has been cancelled
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Has been cancelled
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Has been cancelled
docker / deploy-docs (push) Has been cancelled
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Has been cancelled
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Has been cancelled
arch / build-publish (push) Failing after 43s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 17:02:17 +00:00
enricobuehler c758b0393a docs: sysext + pacman repo are the Bazzite/Arch install paths
apple / swift (push) Successful in 1m8s
ci / rust (push) Successful in 1m37s
ci / web (push) Successful in 53s
android / android (push) Successful in 3m37s
ci / docs-site (push) Successful in 58s
apple / screenshots (push) Successful in 5m23s
ci / bench (push) Successful in 4m52s
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
deb / build-publish (push) Successful in 4m36s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 52s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 9m57s
docker / deploy-docs (push) Successful in 20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m40s
arch / build-publish (push) Has been cancelled
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:01 +00:00
enricobuehler d6a659a1ee feat(packaging/arch): distribute binary packages via the Gitea Arch registry
New arch.yml builds the split PKGBUILD (host/client/web, PF_WITH_WEB=1) in an
archlinux:base-devel container on every push and publishes to the pacman repos
'punktfunk' (tags) / 'punktfunk-canary' (main, X.Y.Z-0.<run#> — pkgrel allows
only digits+dots, so the run number carries the ordering). Consumers add one
pacman.conf section; no more build-it-yourself as the only Arch path.

PKGBUILD: pkgver/pkgrel env-driven (PF_PKGVER/PF_PKGREL), source=() when
PF_SRCDIR is set (a canary version has no tag to clone), stale NVENC-only
header fixed, and options=('!lto' '!debug') — makepkg's lto option injects
-flto=auto into CFLAGS, aws-lc-sys compiles its C with it, and rust's lld
cannot read GCC LTO bitcode: 'undefined symbol: aws_lc_*' at link (reproduced
minimally on Arch + rust 1.90). Full build + clean-container install
smoke-tested locally (binaries run, payload + scriptlets intact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:01 +00:00
enricobuehler 2190dad2ad feat(packaging/bazzite): systemd-sysext replaces rpm-ostree layering as the primary install path
Layering is a last resort per the Bazzite docs (slows every OS update, can
block upgrades until removed); a sysext never enters an rpm-ostree
transaction, survives OS updates, and installs/updates with no reboot —
the mechanism Fedora Atomic ships via fedora-sysexts.

- build-sysext.sh wraps the built host+web RPMs into punktfunk-<V-R>-x86-64.raw:
  /etc payload relocated to /usr/share/punktfunk/etc (a sysext carries only
  /usr), the punktfunk-sysext helper embedded, ID=fedora + VERSION_ID pinned
  (merges on Bazzite via ID_LIKE; REFUSED after a major rebase instead of
  running soname-broken binaries — both behaviors validated live on Bazzite 43).
  SELinux labels are baked in as squashfs pseudo-xattrs from matchpathcon:
  unlabeled files run fine for user units but system daemons are DENIED
  (udev couldn't read the gamepad rule under enforcing) — validated on-glass.
  Refuses duplicate input package names (a stale noarch punktfunk-web next to
  the x86_64 one built a chimera image with the dead node launcher once).
- punktfunk-sysext.sh: install/update/status/remove against per-Fedora-major
  feeds (…/generic/punktfunk-sysext/f43[-canary]), SHA-256-verified, applies
  the udev/sysctl scriptlet work + /etc copies, prints the layering-migration
  hint. Live-validated on the .41 Bazzite box incl. service restart + web console.
- publish-sysext-feed.sh + rpm.yml: build + publish the image per matrix leg
  (fedver 43/44), canary feeds pruned to 6, stable release assets attached.
- update-punktfunk.sh warns when the sysext shadows a layered install.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 16:39:01 +00:00
enricobuehler 5b5ec15ead fix(client-linux): GL presenter — eglCreateImageKHR takes EGLint attribs, not EGLAttrib
apple / swift (push) Successful in 1m12s
apple / screenshots (push) Successful in 5m47s
android / android (push) Successful in 3m18s
ci / rust (push) Successful in 1m35s
ci / web (push) Successful in 55s
ci / docs-site (push) Successful in 1m35s
ci / bench (push) Successful in 4m57s
decky / build-publish (push) Successful in 15s
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 5s
deb / build-publish (push) Successful in 4m37s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
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
flatpak / build-publish (push) Successful in 4m18s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m57s
docker / deploy-docs (push) Successful in 6s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m14s
The KHR variant reads 32-bit attrib pairs; the pointer-sized array fed it
garbage and every plane import came back rejected (observed on-Deck; the
new fallback ladder caught it and demoted to software exactly as designed).
Also print the real EGL error enum instead of its discriminant.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:32:06 +00:00
enricobuehler c9ff144492 Merge branch 'main' of git.unom.io:unom/punktfunk
apple / swift (push) Successful in 1m6s
ci / rust (push) Successful in 1m49s
ci / web (push) Successful in 51s
ci / docs-site (push) Successful in 1m6s
android / android (push) Successful in 3m51s
windows-host / package (push) Successful in 6m56s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 1m22s
deb / build-publish (push) Successful in 4m40s
ci / bench (push) Successful in 4m48s
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 5s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
release / apple (push) Successful in 7m51s
decky / build-publish (push) Successful in 24s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Successful in 1m19s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 50s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m17s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 58s
flatpak / build-publish (push) Successful in 4m12s
apple / screenshots (push) Successful in 5m39s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 9m50s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 9m41s
docker / deploy-docs (push) Successful in 20s
2026-07-04 14:29:40 +00:00
enricobuehler 7930d2f0f4 fix(core): split WIRE_VERSION from ABI_VERSION — new clients locked out of every deployed host
ABI_VERSION was doing double duty: the embeddable C surface AND the punktfunk/1
Hello/Welcome version that hosts equality-check. The WoL feature's v3 bump added
a client-local FFI function without changing a single wire byte — and every new
client started refusing against every deployed host ("ABI mismatch: client 3
host 2", observed live Deck → Bazzite). The wire now carries its own
WIRE_VERSION (still 2); ABI_VERSION stays 3 for the C header and the mgmt API's
informational field. Bump WIRE_VERSION only when the handshake/planes actually
change incompatibly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-04 14:29:33 +00:00
180 changed files with 17893 additions and 2162 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ jobs:
java-version: "21" java-version: "21"
- name: Android SDK - name: Android SDK
uses: android-actions/setup-android@v3 # SHA-pinned for parity with android.yml (third-party action). v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
# No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP # No NDK/CMake — the screenshot unit tests are pure JVM. compileSdk 37 auto-downloads via AGP
# if the platform channel lacks it (same note as android.yml). # if the platform channel lacks it (same note as android.yml).
+6 -4
View File
@@ -1,5 +1,5 @@
# Android client CI (Gitea Actions). Builds the Rust JNI core (clients/android/native) via # 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. # 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 # Prereq: the runner needs ~6 GB free + internet (it pulls the Android SDK/NDK and the Gradle
@@ -40,10 +40,12 @@ jobs:
fi fi
RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")" RUSTUP="$(command -v rustup || echo "$HOME/.cargo/bin/rustup")"
dirname "$RUSTUP" >> "$GITHUB_PATH" 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 - name: Android SDK
uses: android-actions/setup-android@v3 # SHA-pinned: this workflow's release job carries the signing keystore + Play service-account
# secrets, so a moved tag on a third-party action could exfiltrate them. v3 = 9fc6c4e.
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
- name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build) - name: NDK r30 + platform 36 + build-tools + CMake (libopus cross-build)
# cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path # cmake;3.22.1 installs cmake + ninja under $ANDROID_SDK/cmake/3.22.1/bin — the exact path
@@ -98,7 +100,7 @@ jobs:
RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }}
run: | run: |
echo "${{ secrets.RELEASE_KEYSTORE_BASE64 }}" | base64 -d > release.jks 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 ./gradlew :app:bundleRelease :app:assembleRelease --stacktrace
# Publish BEFORE the Play upload so artifacts land even while the Play step is still failing. # Publish BEFORE the Play upload so artifacts land even while the Play step is still failing.
+142
View File
@@ -0,0 +1,142 @@
# Build the punktfunk-host / punktfunk-client / punktfunk-web pacman packages from
# packaging/arch/PKGBUILD and publish them to Gitea's Arch package registry, so Arch boxes
# get new builds via `pacman -Syu`. Counterpart to deb.yml (apt) and rpm.yml (dnf/rpm-ostree).
# Arch is rolling, so the packages build against whatever the archlinux:base-devel container
# resolves today — the same sonames an up-to-date Arch box runs.
#
# Registry (public, unom org) — box setup (once), see packaging/arch/README.md. The registry
# SIGNS the DB + packages, so the box imports the registry key first (pacman-key --add +
# --lsign-key), then no SigLevel line is needed (pacman's default Required verifies):
# [punktfunk] # or [punktfunk-canary] for main-push builds
# Server = https://git.unom.io/api/packages/unom/arch/$repo/$arch
#
# REGISTRY_TOKEN: repo Actions secret, a PAT with write:package scope (shared with docker.yml).
# NOTE: this token + the registry-held private key are the trust root — a token holder can
# publish a validly-signed package (the signature attests "via the registry", not "built by CI").
name: arch
on:
push:
branches: [main]
# Single project version: a `vX.Y.Z` tag is THE release. main publishes to the
# `punktfunk-canary` pacman repo as X.Y.Z-0.<run#> (sorts below the eventual X.Y.Z-1),
# tags to `punktfunk` — separate repos, so neither channel can shadow the other.
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: git.unom.io
OWNER: unom
jobs:
build-publish:
runs-on: ubuntu-24.04
container:
image: docker.io/library/archlinux:base-devel
timeout-minutes: 90
env:
CARGO_HOME: /usr/local/cargo
steps:
# git + nodejs must exist before actions/checkout — base-devel ships neither, and
# act_runner runs the action's JS with the CONTAINER's node, it does not inject one.
- name: Install build + runtime-dev deps
run: |
pacman -Syu --noconfirm --needed \
git nodejs rust clang cmake nasm pkgconf python \
gtk4 libadwaita sdl3 ffmpeg pipewire wayland libxkbcommon opus libei \
mesa libglvnd unzip libarchive
# bun builds the punktfunk-web console AND is vendored as its runtime (PF_WITH_WEB=1);
# it's AUR-only on Arch, so bootstrap the official binary.
command -v bun >/dev/null || {
curl -fsSL https://bun.sh/install | bash
install -m0755 "$HOME/.bun/bin/bun" /usr/local/bin/bun
}
bun --version
- uses: actions/checkout@v4
# Cache cargo's git dir too, not just the registry: the workspace includes
# clients/windows, whose windows-reactor/windows deps are git-pinned — cargo must CLONE
# them (windows-rs is huge) merely to resolve the workspace, even though nothing Windows
# is ever compiled here. Cached, that cost is paid once per runner.
- uses: actions/cache@v4
with:
path: |
/usr/local/cargo/registry
/usr/local/cargo/git
key: cargo-home-arch-${{ hashFiles('Cargo.lock') }}
restore-keys: cargo-home-arch-
- name: Version + channel
# vX.Y.Z tag -> X.Y.Z-1 in the `punktfunk` repo; main push -> <next-minor>-0.<run#> in
# `punktfunk-canary` (pkgrel accepts only digits+dots — the run number carries the
# monotonic ordering; the commit sha is stamped into the binary via the workflow log).
run: |
eval "$(bash scripts/ci/pf-version.sh)" # -> PF_BASE (one minor ahead of latest stable)
case "$GITHUB_REF" in
refs/tags/v*) V="${GITHUB_REF_NAME#v}"; R="1"; REPO=punktfunk ;;
*) V="$PF_BASE"; R="0.${GITHUB_RUN_NUMBER}"; REPO=punktfunk-canary ;;
esac
echo "PF_PKGVER=$V" >> "$GITHUB_ENV"
echo "PF_PKGREL=$R" >> "$GITHUB_ENV"
echo "REPO=$REPO" >> "$GITHUB_ENV"
echo "pacman $V-$R -> repo '$REPO'"
- name: Build packages (makepkg)
run: |
git config --global --add safe.directory "$PWD"
# libcuda link stub — same trick as packaging/rpm/build-rpm.sh: the zerocopy FFI
# links -lcuda but the builder has no GPU; synthesize every cu* symbol the source
# references so a newly-added call can't silently break the link.
CU_SYMS="$(grep -rhoE '\bcu[A-Z][A-Za-z0-9_]*' crates/punktfunk-host/src/ | sort -u || true)"
if [ -n "$CU_SYMS" ] && [ ! -e /usr/lib/libcuda.so ]; then
STUB_C="$(mktemp --suffix=.c)"
for s in $CU_SYMS; do printf 'int %s(void){return 0;}\n' "$s" >> "$STUB_C"; done
gcc -shared -fPIC -Wl,-soname,libcuda.so.1 -o /usr/lib/libcuda.so.1 "$STUB_C"
ln -sf libcuda.so.1 /usr/lib/libcuda.so
rm -f "$STUB_C"; ldconfig
echo "== libcuda stub: $(printf '%s\n' "$CU_SYMS" | wc -l) symbols =="
fi
# makepkg refuses to run as root; deps are already installed above (-d skips the
# RPM-level check that can't see the script-installed bun anyway).
useradd -m builder
mkdir -p "$CARGO_HOME" # actions/cache doesn't create it on a cache miss
chown -R builder: "$PWD" "$CARGO_HOME"
sudo -u builder git config --global --add safe.directory "$PWD"
mkdir -p dist && chown builder: dist
cd packaging/arch
sudo -u builder env PF_SRCDIR="$GITHUB_WORKSPACE" PF_WITH_WEB=1 \
PF_PKGVER="$PF_PKGVER" PF_PKGREL="$PF_PKGREL" \
CARGO_HOME="$CARGO_HOME" PKGDEST="$GITHUB_WORKSPACE/dist" \
makepkg -f -d --holdver
ls -lh "$GITHUB_WORKSPACE/dist"
- name: Publish to the Gitea Arch registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
for pkg in dist/*.pkg.tar.zst; do
echo "uploading $pkg"
NAME=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgname = //p')
VER=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^pkgver = //p')
ARCH=$(bsdtar -xOf "$pkg" .PKGINFO | sed -n 's/^arch = //p')
# A re-tagged release re-fires this workflow and the registry 409s on duplicate
# package versions — delete any prior copy first (404 on the first publish is fine).
curl -fsS -o /dev/null --user "enricobuehler:$TOKEN" -X DELETE \
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO/$NAME/$VER/$ARCH" || true
curl -fsS --user "enricobuehler:$TOKEN" --upload-file "$pkg" \
"https://$REGISTRY/api/packages/$OWNER/arch/$REPO"
done
echo "published to $OWNER/arch/$REPO"
# On a real release, also attach the packages to the unified Gitea Release.
- name: Attach packages to the Gitea release (stable tags only)
if: startsWith(gitea.ref, 'refs/tags/v')
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
. scripts/ci/gitea-release.sh
RID=$(ensure_release "$GITHUB_REF_NAME" "$GITHUB_REF_NAME" auto)
for pkg in dist/*.pkg.tar.zst; do
upsert_asset "$RID" "$pkg"
done
+30 -5
View File
@@ -1,7 +1,10 @@
# Supply-chain advisory scan for the (network-facing, crypto-heavy) Rust dependency tree. # Supply-chain advisory scan for BOTH dependency trees the project ships to users:
# Runs `cargo audit` against the RustSec advisory DB: weekly (catch newly-disclosed CVEs in # * cargo-audit → the (network-facing, crypto-heavy) Rust tree, against the RustSec advisory DB.
# pinned deps), on every Cargo.lock change (catch a bad dep the moment it lands), and on demand. # * bun audit → the web management console (Nitro/Bun BFF) — the component that holds the login
# To silence a known-unfixable advisory, add it to `.cargo/audit.toml` ([advisories] ignore = [...]). # gate, session sealing, and the mgmt bearer token, so its deps matter too.
# Triggers: weekly (catch newly-disclosed CVEs in pinned deps), on every lockfile change (catch a bad
# dep the moment it lands), and on demand.
# To silence a known-unfixable Rust advisory, add it to `.cargo/audit.toml` ([advisories] ignore=[…]).
name: audit name: audit
on: on:
@@ -9,7 +12,7 @@ on:
- cron: '0 6 * * 1' # Mondays 06:00 UTC - cron: '0 6 * * 1' # Mondays 06:00 UTC
push: push:
branches: [main] branches: [main]
paths: ['Cargo.lock', '.gitea/workflows/audit.yml'] paths: ['Cargo.lock', 'web/bun.lock', '.gitea/workflows/audit.yml']
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -31,3 +34,25 @@ jobs:
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit command -v cargo-audit >/dev/null 2>&1 || cargo install --locked cargo-audit
cargo audit cargo audit
bun-audit:
runs-on: ubuntu-24.04
container:
image: oven/bun:1
timeout-minutes: 15
defaults:
run:
working-directory: web
steps:
# oven/bun's slim base lacks a CA bundle + git — actions/checkout's HTTPS fetch needs them
# (same preamble as web-screenshots.yml / ci.yml's web job).
- name: Install git + CA certs
working-directory: /
run: apt-get update && apt-get install -y --no-install-recommends ca-certificates git
- uses: actions/checkout@v4
# `bun audit` queries the registry advisory DB for the versions pinned in web/bun.lock. No
# install/build needed — it reads the manifest + lockfile. Fails the job on any advisory, the
# same fail-on-vulnerability stance as cargo-audit above; triage a finding by bumping the dep
# (or, if genuinely unfixable + inapplicable, pinning a resolution and noting why here).
- name: bun audit
run: bun audit
+2 -2
View File
@@ -28,8 +28,8 @@ jobs:
# Best-effort caches (act_runner's built-in cache server). Keyed on Cargo.lock: # 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 # registry/git are download caches, target/ the incremental build. The target key
# carries the rustc version — rust-toolchain.toml pins the floating "stable" # carries the rustc version — resolved via `rustc --version` (below) rather than parsed
# channel, so the file alone wouldn't invalidate stale incremental state. # from rust-toolchain.toml, so a pin bump there invalidates stale incremental state too.
- name: Cache keys - name: Cache keys
run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV" run: echo "rustc=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_ENV"
- uses: actions/cache@v4 - uses: actions/cache@v4
+6 -2
View File
@@ -86,7 +86,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Sync compose file - name: Sync compose file
uses: appleboy/scp-action@v0.1.7 # SHA-pinned (not tag-pinned): this action receives DEPLOY_SSH_KEY + host/user/port, so a
# moved tag would mean credential exfiltration. v0.1.7 = 917f8b8. Bump both the SHA and the
# trailing version together when upgrading.
uses: appleboy/scp-action@917f8b81dfc1ccd331fef9e2d61bdc6c8be94634 # v0.1.7
with: with:
host: ${{ secrets.DEPLOY_HOST }} host: ${{ secrets.DEPLOY_HOST }}
username: ${{ secrets.DEPLOY_USER }} username: ${{ secrets.DEPLOY_USER }}
@@ -97,7 +100,8 @@ jobs:
overwrite: true overwrite: true
- name: Pull and start docs - name: Pull and start docs
uses: appleboy/ssh-action@v1.2.5 # SHA-pinned: receives DEPLOY_SSH_KEY + REGISTRY_TOKEN (see the scp step above). v1.2.5 = 0ff4204.
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
env: env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }} REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
with: with:
+28
View File
@@ -35,8 +35,10 @@ jobs:
include: include:
- image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base - image: punktfunk-fedora-rpm # Fedora 43 == Bazzite base
group: bazzite group: bazzite
fedver: 43
- image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin - image: punktfunk-fedora44-rpm # Fedora 44 == Fedora KDE spin
group: fedora-44 group: fedora-44
fedver: 44
container: container:
image: git.unom.io/unom/${{ matrix.image }}:latest image: git.unom.io/unom/${{ matrix.image }}:latest
timeout-minutes: 90 timeout-minutes: 90
@@ -53,6 +55,8 @@ jobs:
run: | run: |
git config --global --add safe.directory "$PWD" git config --global --add safe.directory "$PWD"
dnf -y install gtk4-devel libadwaita-devel SDL3-devel dnf -y install gtk4-devel libadwaita-devel SDL3-devel
# sysext build (packaging/bazzite/build-sysext.sh): squashfs + SELinux labeling.
dnf -y install squashfs-tools cpio libselinux-utils selinux-policy-targeted
# bun builds the punktfunk-web console (--with web). Baked into the image; install it # bun builds the punktfunk-web console (--with web). Baked into the image; install it
# here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note). # here too so the job stays green against the PREVIOUS image (docker.yml bootstrap note).
command -v bun >/dev/null || { command -v bun >/dev/null || {
@@ -117,6 +121,27 @@ jobs:
done done
echo "published to $OWNER/rpm/$GROUP" echo "published to $OWNER/rpm/$GROUP"
# The no-layering Bazzite path: wrap the just-built host + web RPMs into a systemd-sysext
# image and publish it to the per-Fedora-major feed (punktfunk-sysext/f43[-canary], …) that
# `punktfunk-sysext install|update` reads. Same RPMs, same channels — just no rpm-ostree.
- name: Build the sysext image
run: |
bash packaging/bazzite/build-sysext.sh --version-id "${{ matrix.fedver }}" \
--out "dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw" \
dist/punktfunk-"${PF_VERSION}-${PF_RELEASE}"*.rpm \
dist/punktfunk-web-"${PF_VERSION}-${PF_RELEASE}"*.rpm
- name: Publish the sysext feed
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
case "$GROUP" in
*-canary) FEED="f${{ matrix.fedver }}-canary"; KEEP=6 ;; # rolling: bound the pile-up
*) FEED="f${{ matrix.fedver }}"; KEEP=0 ;; # stable: keep every release
esac
KEEP=$KEEP bash packaging/bazzite/publish-sysext-feed.sh "$FEED" \
"dist-sysext/punktfunk-${PF_VERSION}-${PF_RELEASE}-x86-64.raw"
# On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases # On a real release, also attach the .rpms to the unified Gitea Release. Both Fedora bases
# (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep # (bazzite=F43, fedora-44) build the SAME filename, so suffix the asset with the base to keep
# both on the release; canary builds live in the `*-canary` rpm groups (no release page). # both on the release; canary builds live in the `*-canary` rpm groups (no release page).
@@ -132,3 +157,6 @@ jobs:
base="$(basename "$rpm" .rpm)" base="$(basename "$rpm" .rpm)"
upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm" upsert_asset "$RID" "$rpm" "${base}.${{ matrix.group }}.rpm"
done done
for raw in dist-sysext/*.raw; do
upsert_asset "$RID" "$raw" "$(basename "$raw" .raw).f${{ matrix.fedver }}.raw"
done
Generated
+9 -9
View File
@@ -2129,7 +2129,7 @@ dependencies = [
[[package]] [[package]]
name = "latency-probe" name = "latency-probe"
version = "0.7.2" version = "0.8.0"
[[package]] [[package]]
name = "lazy_static" name = "lazy_static"
@@ -2261,7 +2261,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "loss-harness" name = "loss-harness"
version = "0.7.2" version = "0.8.0"
dependencies = [ dependencies = [
"punktfunk-core", "punktfunk-core",
] ]
@@ -2908,7 +2908,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-android" name = "punktfunk-client-android"
version = "0.7.2" version = "0.8.0"
dependencies = [ dependencies = [
"android_logger", "android_logger",
"jni", "jni",
@@ -2922,7 +2922,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-linux" name = "punktfunk-client-linux"
version = "0.7.2" version = "0.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2945,7 +2945,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-client-windows" name = "punktfunk-client-windows"
version = "0.7.2" version = "0.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-channel", "async-channel",
@@ -2968,7 +2968,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-core" name = "punktfunk-core"
version = "0.7.2" version = "0.8.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"bytes", "bytes",
@@ -2999,7 +2999,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-host" name = "punktfunk-host"
version = "0.7.2" version = "0.8.0"
dependencies = [ dependencies = [
"aes", "aes",
"aes-gcm", "aes-gcm",
@@ -3071,7 +3071,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-probe" name = "punktfunk-probe"
version = "0.7.2" version = "0.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"mdns-sd", "mdns-sd",
@@ -3085,7 +3085,7 @@ dependencies = [
[[package]] [[package]]
name = "punktfunk-tray" name = "punktfunk-tray"
version = "0.7.2" version = "0.8.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"ksni", "ksni",
+1 -1
View File
@@ -17,7 +17,7 @@ members = [
exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"] exclude = ["packaging/linux/steam-deck-gadget/usbip-poc"]
[workspace.package] [workspace.package]
version = "0.7.2" version = "0.8.0"
edition = "2021" edition = "2021"
rust-version = "1.82" rust-version = "1.82"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
+9 -3
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 - **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 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. 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; - **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 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 monitor or dummy HDMI plug, even on the secure desktop (UAC / lock screen). It also has **its own
@@ -52,7 +57,7 @@ protocol, FEC, and crypto, linked into the host and every client over a stable C
| **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened | | **Core**`punktfunk-core` + C ABI (protocol · FEC · crypto · QUIC) | ✅ Complete & hardened |
| **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads | | **GameStream host** → stock Moonlight | ✅ Live end-to-end: pairing, RTSP, audio, per-client virtual output at native resolution, GPU zero-copy NVENC, gamepads |
| **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation | | **Native protocol**`punktfunk/1` | ✅ Validated live: QUIC control + GF(2¹⁶) FEC/AES-GCM data plane, PIN pairing, mDNS discovery, mid-stream mode renegotiation |
| **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: DXGI/WGC capture · its own all-Rust IddCx **virtual display** (secure-desktop capable) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green | | **Windows host** (Windows 11 22H2+, x64) | 🟡 Implemented & shipping as a signed installer: its own all-Rust IddCx **virtual display** (secure-desktop capable) with a **sealed IDD-push** capture path — finished frames pushed straight into its own driver, not screen-scraped (no DDA/WGC) · GPU encode (NVENC on NVIDIA, AMF/QSV on AMD/Intel, software H.264 without a GPU) · WASAPI audio · bundled virtual-gamepad drivers (no ViGEmBus) · HDR incl. Vulkan-game HDR. NVIDIA live-validated; AMD/Intel CI-green |
| **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test | | **macOS / iOS / tvOS client** (`clients/apple`) | ✅ Streaming live: VideoToolbox decode, controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch | | **Linux client** (`clients/linux`, GTK4) | ✅ Streaming live: FFmpeg + VAAPI zero-copy decode, PipeWire audio, SDL3 controllers; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing | | **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
@@ -83,8 +88,9 @@ Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
| Platform | Install | Guide | | Platform | Install | Guide |
|--------|---------|-------| |--------|---------|-------|
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) | | **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu — GNOME](https://docs.punktfunk.unom.io/docs/ubuntu-gnome) · [KDE](https://docs.punktfunk.unom.io/docs/ubuntu-kde) |
| **Fedora / Bazzite** (rpm-ostree) | `rpm-ostree install punktfunk punktfunk-web` *(or the bootc image)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) · [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) | | **Bazzite / Fedora Atomic** (systemd-sysext) | `sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Arch / Steam Deck** (PKGBUILD / sysext) | `makepkg -si` *(Arch)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) | | **Fedora** (dnf) | `dnf install punktfunk punktfunk-web` *(after adding the repo)* | [Fedora — KDE](https://docs.punktfunk.unom.io/docs/fedora-kde) |
| **Arch / Steam Deck** (pacman / sysext) | `pacman -Sy punktfunk-host` *(binary repo)* · sysext `.raw` *(SteamOS)* | [packaging/arch](packaging/arch/README.md) |
| **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) | | **Windows** (11 22H2+, x64) | signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status). `punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
+670 -2
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0", "name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0" "identifier": "MIT OR Apache-2.0"
}, },
"version": "0.6.0" "version": "0.8.0"
}, },
"paths": { "paths": {
"/api/v1/clients": { "/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": { "/api/v1/gpus": {
"get": { "get": {
"tags": [ "tags": [
@@ -1601,6 +1819,99 @@
"av1" "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": { "ApiError": {
"type": "object", "type": "object",
"description": "Error envelope for every non-2xx response.", "description": "Error envelope for every non-2xx response.",
@@ -1909,6 +2220,150 @@
} }
} }
}, },
"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": {
"game_session": {
"$ref": "#/components/schemas/GameSession",
"description": "How a game-launching session is served (`design/gamemode-and-dedicated-sessions.md` §5.2).\nOrthogonal to `preset`/lifecycle — preserved across preset changes; `#[serde(default)]` = `Auto`\nso existing `display-settings.json` files are untouched."
},
"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": { "GameEntry": {
"type": "object", "type": "object",
"description": "One title in the unified library, regardless of which store it came from.", "description": "One title in the unified library, regardless of which store it came from.",
@@ -1948,6 +2403,14 @@
} }
} }
}, },
"GameSession": {
"type": "string",
"description": "How a session that **launches a game** (a library id on the Hello / apps.json / Decky pin) is\nserved (`design/gamemode-and-dedicated-sessions.md` §5.2). Orthogonal to the preset/lifecycle axes\n— a top-level [`DisplayPolicy`] field, NOT part of [`EffectivePolicy`], so a preset never clobbers\nit. Linux-only in effect (a launching Windows session opens into the one desktop).",
"enum": [
"auto",
"dedicated"
]
},
"GpuState": { "GpuState": {
"type": "object", "type": "object",
"description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.", "description": "Full GPU-selection state for the console: inventory, the persisted preference, what the next\nsession will use, and what is in use right now.",
@@ -2099,6 +2562,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": { "LaunchSpec": {
"type": "object", "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.", "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 +2647,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": { "LocalSummary": {
"type": "object", "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.", "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 +2683,20 @@
"paired_clients", "paired_clients",
"native_paired_clients", "native_paired_clients",
"pin_pending", "pin_pending",
"pending_approvals" "pending_approvals",
"kept_displays"
], ],
"properties": { "properties": {
"audio_streaming": { "audio_streaming": {
"type": "boolean", "type": "boolean",
"description": "True while the audio stream thread is running." "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": { "native_paired_clients": {
"type": "integer", "type": "integer",
"format": "int32", "format": "int32",
@@ -2242,6 +2804,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": { "NativeClient": {
"type": "object", "type": "object",
"description": "A paired native (punktfunk/1) client.", "description": "A paired native (punktfunk/1) client.",
@@ -2439,6 +3011,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": { "RuntimeStatus": {
"type": "object", "type": "object",
"description": "Live host status (changes as clients launch/end sessions).", "description": "Live host status (changes as clients launch/end sessions).",
@@ -2740,6 +3394,16 @@
"example": "1234" "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": { "securitySchemes": {
@@ -2763,6 +3427,10 @@
"name": "gpu", "name": "gpu",
"description": "GPU inventory and selection: list the host's GPUs, choose automatic or a preferred GPU, see the one in use" "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", "name": "clients",
"description": "Paired Moonlight client management" "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. pairing** (or TOFU on trusted LANs), then reconnects on a Keystore-wrapped, pinned identity.
- **Compose UI** — Connect / Settings / Stream screens with Material You theming. - **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 ## 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`, **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 `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 `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). 2026.05.01 · compileSdk 37 · minSdk 31).
+33 -4
View File
@@ -22,14 +22,34 @@ android {
} }
applicationId = "io.unom.punktfunk" 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 targetSdk = 36
val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE")) val vCode = (props.getProperty("VERSION_CODE") ?: System.getenv("VERSION_CODE"))
versionCode = vCode?.toInt() ?: 1 versionCode = vCode?.toInt() ?: 1
// versionName is the single project version, threaded from CI (a vX.Y.Z release or a // 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). // canary string). versionCode stays the monotonic run number (Play rejects regressions).
versionName = (props.getProperty("VERSION_NAME") ?: System.getenv("VERSION_NAME")) ?: "0.0.2" // Local dev (no VERSION_NAME) falls back to the workspace version from the root Cargo.toml —
ndk { abiFilters += listOf("arm64-v8a", "x86_64") } // 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 { signingConfigs {
@@ -97,9 +117,18 @@ dependencies {
implementation("androidx.compose.ui:ui-tooling-preview") implementation("androidx.compose.ui:ui-tooling-preview")
implementation("androidx.compose.foundation:foundation") implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.material3:material3") 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") 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: // Android TV components (we target phone + TV) land in the TV-UI milestone:
// implementation("androidx.tv:tv-material:1.1.0") // implementation("androidx.tv:tv-material:1.1.0")
// The manifest already declares leanback so the scaffold installs on TV. // The manifest already declares leanback so the scaffold installs on TV.
@@ -36,12 +36,21 @@
<application <application
android:allowBackup="false" android:allowBackup="false"
android:appCategory="game" android:appCategory="game"
android:banner="@drawable/tv_banner"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.PunktfunkAndroid"> android:theme="@style/Theme.PunktfunkAndroid">
<!-- Game Mode config (Android 13+): declare we support Performance mode and opt OUT of the
OEM interventions that would fight the negotiated stream — resolution downscaling and
FPS overrides. A game-streaming client renders exactly the host's mode; a platform
downscale/FPS-cap corrupts that. Ignored below API 33. -->
<meta-data
android:name="android.game_mode_config"
android:resource="@xml/game_mode_config" />
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -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.AnimatedContent
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.Crossfade
import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box 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.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext 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 import io.unom.punktfunk.models.Tab
@Composable @Composable
fun App() { fun App(forceGamepadUi: Boolean = false) {
val context = LocalContext.current val context = LocalContext.current
val settingsStore = remember { SettingsStore(context) } val settingsStore = remember { SettingsStore(context) }
var settings by remember { mutableStateOf(settingsStore.load()) } var settings by remember { mutableStateOf(settingsStore.load()) }
var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming var streamHandle by remember { mutableLongStateOf(0L) } // 0 = not streaming
var tab by remember { mutableStateOf(Tab.Connect) } 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( AnimatedContent(
targetState = streamHandle != 0L, targetState = streamHandle != 0L,
transitionSpec = { transitionSpec = {
@@ -46,29 +68,34 @@ fun App() {
if (isStreaming) { if (isStreaming) {
// Immersive: the stream takes the whole screen, no bottom bar. // Immersive: the stream takes the whole screen, no bottom bar.
StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L }) StreamScreen(streamHandle, micEnabled = settings.micEnabled, onDisconnect = { streamHandle = 0L })
} else { } else if (gamepadUi) {
Scaffold( GamepadShell(
bottomBar = { settings = settings,
NavigationBar { onSettingsChange = { settings = it; settingsStore.save(it) },
Tab.entries.forEach { t -> onConnected = { streamHandle = it },
NavigationBarItem(
selected = tab == t,
onClick = { tab = t },
icon = { Icon(t.icon, contentDescription = t.label) },
label = { Text(t.label) },
) )
} } else {
} // 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
) { innerPadding -> // side navigation). A short landscape phone keeps the bottom bar (rail needs height too).
Box(Modifier.fillMaxSize().padding(innerPadding)) { // 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( AnimatedContent(
targetState = tab, targetState = tab,
transitionSpec = { transitionSpec = {
if (targetState.ordinal > initialState.ordinal) { 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 slideInHorizontally { it } + fadeIn() togetherWith
slideOutHorizontally { -it } + fadeOut() slideOutHorizontally { -it } + fadeOut()
} else { else ->
slideInHorizontally { -it } + fadeIn() togetherWith slideInHorizontally { -it } + fadeIn() togetherWith
slideOutHorizontally { it } + fadeOut() slideOutHorizontally { it } + fadeOut()
} }
@@ -85,7 +112,110 @@ fun App() {
} }
} }
} }
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) },
)
}
}
},
) { 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.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator 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.NativeBridge
import io.unom.punktfunk.kit.security.ClientIdentity import io.unom.punktfunk.kit.security.ClientIdentity
import io.unom.punktfunk.kit.security.KnownHost import io.unom.punktfunk.kit.security.KnownHost
import io.unom.punktfunk.kit.security.KnownHostStore
import io.unom.punktfunk.models.PendingTrust import io.unom.punktfunk.models.PendingTrust
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch 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 * Edit a saved host: name, address, port, and the Wake-on-LAN MAC. The MAC is auto-learned from the
* friendly name like "Living Room" after pairing). Keyed by the host so reopening resets the field. * 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 @Composable
internal fun RenameHostDialog( internal fun EditHostDialog(
target: KnownHost, target: KnownHost,
onRename: (String) -> Unit, suggestedMacs: List<String>,
onSave: (KnownHost) -> Unit,
onDismiss: () -> 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( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Rename host") }, title = { Text("Edit host") },
text = { text = {
Column(
modifier = Modifier.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedTextField( OutlinedTextField(
value = newName, value = name,
onValueChange = { newName = it }, onValueChange = { name = it },
label = { Text("Name") }, label = { Text("Name") },
placeholder = { Text(target.address) }, placeholder = { Text(target.address) },
singleLine = true, 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 = { confirmButton = {
TextButton( TextButton(
enabled = newName.isNotBlank(), enabled = address.isNotBlank(),
onClick = { onRename(newName.trim()) }, onClick = {
onSave(
target.copy(
name = name.trim().ifEmpty { target.address },
address = address.trim(),
port = port.toIntOrNull() ?: target.port,
mac = KnownHostStore.parseMacs(mac),
),
)
},
) { Text("Save") } ) { Text("Save") }
}, },
dismissButton = { dismissButton = {
@@ -84,7 +84,17 @@ private class RequestAccessState(val target: PendingTrust) {
} }
@Composable @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 scope = rememberCoroutineScope()
val context = LocalContext.current val context = LocalContext.current
var host by remember { mutableStateOf("") } var host by remember { mutableStateOf("") }
@@ -124,6 +134,10 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
val identityStore = remember { IdentityStore(context) } val identityStore = remember { IdentityStore(context) }
val knownHostStore = remember { KnownHostStore(context) } val knownHostStore = remember { KnownHostStore(context) }
var savedHosts by remember { mutableStateOf(knownHostStore.all()) } 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), // 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 // 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 // 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) } var pendingTrust by remember { mutableStateOf<PendingTrust?>(null) }
// A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog). // A no-PIN "request access" connect in flight (the cancelable "Waiting for approval…" dialog).
var awaiting by remember { mutableStateOf<RequestAccessState?>(null) } var awaiting by remember { mutableStateOf<RequestAccessState?>(null) }
// A saved host whose label is being edited (the Rename dialog). // A saved host being edited (name / address / port / MAC).
var renameTarget by remember { mutableStateOf<KnownHost?>(null) } 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", // 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 // 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), // The actual dial (identity already ready). On a TOFU connect (pinHex null), pin the fingerprint
// pin the fingerprint the host presented (as an unpaired known host) so the next connect goes // the host presented (as an unpaired known host) so the next connect goes straight through and it
// straight through and it appears in the saved-hosts list. // appears in the saved-hosts list.
fun doConnect(targetHost: String, targetPort: Int, name: String, pinHex: String?) { fun doConnectDirect(targetHost: String, targetPort: Int, name: String, pinHex: String?) {
val id = identity val id = identity ?: run {
if (id == null) {
status = "Identity not ready yet — try again in a moment" status = "Identity not ready yet — try again in a moment"
return return
} }
connecting = true connecting = true
status = "Connecting to $targetHost:$targetPort" 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 discovery.stop() // free the Wi-Fi radio before the stream session
scope.launch { scope.launch {
val handle = connectNative(id, targetHost, targetPort, pinHex ?: "", CONNECT_TIMEOUT_MS) 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 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 // 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 // "Waiting for approval…" dialog meanwhile. The SAME connection is admitted on approval (no
@@ -304,6 +353,61 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
var showManualSheet by remember { mutableStateOf(false) } var showManualSheet by remember { mutableStateOf(false) }
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()) { Box(Modifier.fillMaxSize()) {
LazyVerticalGrid( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp), columns = GridCells.Adaptive(minSize = 160.dp),
@@ -385,13 +489,22 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
knownHostStore.remove(kh.address, kh.port) knownHostStore.remove(kh.address, kh.port)
savedHosts = knownHostStore.all() savedHosts = knownHostStore.all()
}, },
onRename = { renameTarget = kh }, onEdit = { editTarget = kh },
// Explicit wake: offered only when the host is offline and we have a MAC to // Explicit wake-only: offered when the host is offline and we have a MAC. Runs
// target (a tap-to-connect already auto-wakes an offline saved host). // through the WakeController so it shows the "Waking…" overlay and waits for
onWake = if (kh.mac.isNotEmpty() && // the host to come online (matched by fingerprint, so a new DHCP address on a
discovered.none { it.host == kh.address && it.port == kh.port } // cold boot still counts as "up") rather than firing a single silent packet.
) { onWake = if (kh.mac.isNotEmpty() && discovered.none { kh.matches(it) }) {
{ scope.launch(Dispatchers.IO) { NativeBridge.nativeWakeOnLan(kh.mac.joinToString(","), kh.address) } } {
waker.start(
hostName = kh.name,
connectsAfter = false,
macs = kh.mac,
lastIp = kh.address,
isOnline = { discovered.any { kh.matches(it) } },
onOnline = {},
)
}
} else { } else {
null null
}, },
@@ -452,8 +565,20 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
.padding(20.dp), .padding(20.dp),
) )
} }
}
if (showManualSheet) { if (showManualSheet) {
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 = { showManualSheet = false },
)
} else {
AddHostSheet( AddHostSheet(
hostName = hostName, hostName = hostName,
onHostNameChange = { hostName = it }, onHostNameChange = { hostName = it },
@@ -467,64 +592,106 @@ fun ConnectScreen(settings: Settings, onConnected: (Long) -> Unit) {
onConnect = { h2, p, n -> connect(h2, p, manualName = n) }, onConnect = { h2, p, n -> connect(h2, p, manualName = n) },
) )
} }
}
pendingTrust?.let { pt -> pendingTrust?.let { pt ->
when (pt.kind) { // Same trust/pairing logic, console-styled + controller-navigable in gamepad mode.
PendingTrust.Kind.TRUST_NEW -> TrustNewHostDialog( val onPair = { pendingTrust = pt.copy(kind = PendingTrust.Kind.PAIR) }
pt = pt, val onSavePaired = { fp: String ->
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)) knownHostStore.save(KnownHost(pt.host, pt.port, pt.name, fp, paired = true))
savedHosts = knownHostStore.all() savedHosts = knownHostStore.all()
pendingTrust = null pendingTrust = null
doConnect(pt.host, pt.port, pt.name, fp) doConnect(pt.host, pt.port, pt.name, fp)
}, }
onDismiss = { pendingTrust = null }, 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 -> awaiting?.let { req ->
AwaitingApprovalDialog( val onCancel = {
hostLabel = req.target.name,
onCancel = {
req.cancelled.set(true) req.cancelled.set(true)
awaiting = null awaiting = null
connecting = false connecting = false
discovery.start() // the request may still be pending on the host; keep scanning 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 -> editTarget?.let { kh ->
RenameHostDialog( // Prefill a not-yet-learned MAC from the host's live advert, mirroring Apple's
target = kh, // `discovery.hosts.first { host.matches($0) }?.macAddresses`.
onRename = { newName -> val suggested = discovered.firstOrNull { kh.matches(it) }?.mac ?: emptyList()
knownHostStore.rename(kh.address, kh.port, newName) val onSaveHost: (KnownHost) -> Unit = { updated ->
knownHostStore.update(kh.address, kh.port, updated)
savedHosts = knownHostStore.all() savedHosts = knownHostStore.all()
renameTarget = null editTarget = null
}, }
onDismiss = { renameTarget = 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 package io.unom.punktfunk
import android.hardware.input.InputManager import android.hardware.input.InputManager
import android.os.Build
import android.os.CombinedVibration import android.os.CombinedVibration
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
@@ -244,7 +245,7 @@ private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
val canRumble = dev.vibratorManager.vibratorIds.isNotEmpty() val canRumble = deviceHasVibrator(dev)
if (canRumble) { if (canRumble) {
OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") } OutlinedButton(onClick = { testRumble(dev) }) { Text("Test rumble") }
} else { } 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) { private fun testRumble(dev: InputDevice) {
runCatching {
if (Build.VERSION.SDK_INT >= 31) {
val vm = dev.vibratorManager val vm = dev.vibratorManager
if (vm.vibratorIds.isEmpty()) return if (vm.vibratorIds.isEmpty()) return
runCatching {
vm.vibrate(CombinedVibration.createParallel(VibrationEffect.createOneShot(300, 200))) 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.activity.compose.BackHandler
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll 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.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily 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() } context.assets.open("THIRD-PARTY-NOTICES.txt").bufferedReader().use { it.readText() }
}.getOrDefault("Third-party notices unavailable.") }.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 { val version = remember {
runCatching { runCatching {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
@@ -38,23 +52,34 @@ fun LicensesScreen(onBack: () -> Unit) {
}.getOrNull() }.getOrNull()
} }
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( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp), .padding(horizontal = 20.dp)
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
) { ) {
Text("Open-source licenses", style = MaterialTheme.typography.headlineMedium)
if (version != null) { if (version != null) {
Text( Text(
"punktfunk $version", "Punktfunk $version",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Text( Text(
"punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " + "Punktfunk is licensed under MIT OR Apache-2.0, at your option. It uses the open-source " +
"components below, each under its own license.", "components below, each under its own license.",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
@@ -62,5 +87,17 @@ fun LicensesScreen(onBack: () -> Unit) {
notices, notices,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), 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),
)
}
}
} }
} }
@@ -1,5 +1,6 @@
package io.unom.punktfunk package io.unom.punktfunk
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.InputDevice import android.view.InputDevice
import android.view.KeyEvent import android.view.KeyEvent
@@ -10,6 +11,9 @@ import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface 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 androidx.compose.ui.Modifier
import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Keymap import io.unom.punktfunk.kit.Keymap
@@ -34,8 +38,30 @@ class MainActivity : ComponentActivity() {
var padKeyProbe: ((KeyEvent) -> Boolean)? = null var padKeyProbe: ((KeyEvent) -> Boolean)? = null
var padMotionProbe: ((MotionEvent) -> 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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 // 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 // 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.) // 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), statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT),
navigationBarStyle = 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 { setContent {
PunktfunkTheme { 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 { override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handle = streamHandle val handle = streamHandle
if (handle != 0L) { if (handle != 0L) {
@@ -60,9 +112,20 @@ class MainActivity : ComponentActivity() {
if (bit != 0) { if (bit != 0) {
when (event.action) { when (event.action) {
// repeatCount guard: don't re-send a held button as auto-repeat. // 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) 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 return true // consumed
} }
@@ -90,18 +153,29 @@ class MainActivity : ComponentActivity() {
} }
} }
} else { } 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. // The Controllers debug screen sees pad events before the navigation remap below.
padKeyProbe?.let { if (it(event)) return true } padKeyProbe?.let { if (it(event)) return true }
if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) { if (event.isFromSource(InputDevice.SOURCE_GAMEPAD)) {
// Not streaming: a game controller drives the Compose UI (TV + phone). Map the face // 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 // buttons to the navigation the focus system / back stack understand; D-pad *keys*
// move focus on their own, so they fall through to super untouched. // already move focus on their own, so they fall through to super untouched.
val mapped = when (event.keyCode) { when (event.keyCode) {
KeyEvent.KEYCODE_BUTTON_A -> KeyEvent.KEYCODE_DPAD_CENTER // activate focused element // B → back. Drive the OnBackPressedDispatcher directly rather than synthesising a
KeyEvent.KEYCODE_BUTTON_B -> KeyEvent.KEYCODE_BACK // back / dismiss // BACK KeyEvent: a synthetic event isn't "tracking", so the framework's default
else -> 0 // 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) return super.dispatchKeyEvent(event)
@@ -137,6 +211,7 @@ class MainActivity : ComponentActivity() {
if (dir != lastNavDir) { if (dir != lastNavDir) {
lastNavDir = dir lastNavDir = dir
if (dir != 0) { 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_DOWN, dir))
super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir)) super.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, dir))
return true return true
@@ -147,4 +222,17 @@ class MainActivity : ComponentActivity() {
} }
return super.dispatchGenericMotionEvent(event) 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,27 @@ data class Settings(
* understand touch. Mirrors the Apple client's TouchInputMode. * understand touch. Mirrors the Apple client's TouchInputMode.
*/ */
val touchMode: TouchMode = TouchMode.TRACKPAD, 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,
/**
* Aggressive decoder latency tuning — the master escape hatch. On (default): the decoder runs
* the full low-latency profile (per-SoC vendor keys + max-clock operating-rate on Qualcomm).
* Off: a conservative profile (the standard `low-latency` key only), for a device that thermally
* throttles or misbehaves under the aggressive clocks. Decoder ranking, the Wi-Fi low-latency
* lock and HDMI game-mode signalling stay on regardless — they're harmless.
*/
val lowLatencyMode: Boolean = true,
) )
/** [Settings.touchMode] values; persisted by name. */ /** [Settings.touchMode] values; persisted by name. */
@@ -67,6 +88,9 @@ class SettingsStore(context: Context) {
?.let { name -> TouchMode.entries.firstOrNull { it.name == name } } ?.let { name -> TouchMode.entries.firstOrNull { it.name == name } }
// Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct). // Migration: the pre-enum Boolean "trackpad_mode" (true = trackpad, false = direct).
?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER, ?: if (prefs.getBoolean(K_TRACKPAD, true)) TouchMode.TRACKPAD else TouchMode.POINTER,
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
) )
fun save(s: Settings) { fun save(s: Settings) {
@@ -83,6 +107,9 @@ class SettingsStore(context: Context) {
.putBoolean(K_MIC, s.micEnabled) .putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_HUD, s.statsHudEnabled) .putBoolean(K_HUD, s.statsHudEnabled)
.putString(K_TOUCH_MODE, s.touchMode.name) .putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.apply() .apply()
} }
@@ -99,6 +126,9 @@ class SettingsStore(context: Context) {
const val K_MIC = "mic_enabled" const val K_MIC = "mic_enabled"
const val K_HUD = "stats_hud_enabled" const val K_HUD = "stats_hud_enabled"
const val K_TOUCH_MODE = "touch_mode" const val K_TOUCH_MODE = "touch_mode"
const val K_GAMEPAD_UI = "gamepad_ui_enabled"
const val K_LIBRARY = "library_enabled"
const val K_LOW_LATENCY = "low_latency_mode"
/** Legacy Boolean the enum replaced — read once as the migration default, never written. */ /** Legacy Boolean the enum replaced — read once as the migration default, never written. */
const val K_TRACKPAD = "trackpad_mode" const val K_TRACKPAD = "trackpad_mode"
@@ -5,44 +5,79 @@ import android.content.pm.PackageManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.clickable
import androidx.compose.foundation.layout.Arrangement 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.Column
import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll 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.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuAnchorType import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedCard import androidx.compose.material3.OutlinedCard
import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.VerticalDivider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
/** /**
* Stream settings, grouped into Display / Host / Audio / Overlay cards. Edits are persisted * Stream settings, organised as an iOS-Settings / Android-system-settings style list of category
* immediately via [onChange]; [onBack] returns to the connect screen. Resolution/refresh "Native" * subpages. On a phone the category list pushes to a full-screen detail; on a tablet / large screen
* resolve from the device display at connect time. * 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 @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) } var s by remember { mutableStateOf(initial) }
val context = LocalContext.current val context = LocalContext.current
var showLicenses by remember { mutableStateOf(false) } var showLicenses by remember { mutableStateOf(false) }
@@ -52,13 +87,20 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
onChange(next) onChange(next)
} }
BackHandler(onBack = onBack)
// Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off. // Mic uplink — turning it on requests RECORD_AUDIO; if denied, the toggle stays off.
val micLauncher = rememberLauncherForActivityResult( val micLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission(),
) { granted -> update(s.copy(micEnabled = granted)) } ) { 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) { if (showLicenses) {
LicensesScreen(onBack = { showLicenses = false }) LicensesScreen(onBack = { showLicenses = false })
return return
@@ -68,23 +110,183 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
return return
} }
// 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 } }
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
}
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,
)
}
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()
}
},
label = "SettingsPush",
) { sel ->
if (sel == null) {
CategoryList(
selected = null,
twoPane = false,
onSelect = { selectedName = it.name },
modifier = Modifier.fillMaxSize(),
)
} else {
detail(sel) { selectedName = null }
}
}
}
}
}
/** 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 CategoryList(
selected: SettingsCategory?,
twoPane: Boolean,
onSelect: (SettingsCategory) -> Unit,
modifier: Modifier = Modifier,
) {
Column( Column(
modifier
.verticalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 20.dp),
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
Text(
"Settings",
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(start = 8.dp, bottom = 12.dp),
)
SettingsCategory.entries.forEach { cat ->
val highlighted = twoPane && selected == cat
Row(
modifier = Modifier 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() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(horizontal = 20.dp, vertical = 24.dp), .padding(horizontal = 20.dp, vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp),
) { ) {
Text("Settings", style = MaterialTheme.typography.headlineMedium) 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) val (nw, nh, nhz) = nativeDisplayMode(context)
SettingsCard {
SettingsGroup("Display") {
SettingDropdown( SettingDropdown(
label = "Resolution", label = "Resolution",
options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> options = RESOLUTION_OPTIONS.map { (w, h, lbl) -> (w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl) },
(w to h) to (if (w == 0) "$lbl ($nw × $nh)" else lbl)
},
selected = s.width to s.height, selected = s.width to s.height,
) { (w, h) -> update(s.copy(width = w, height = h)) } ) { (w, h) -> update(s.copy(width = w, height = h)) }
@@ -94,21 +296,16 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
selected = s.hz, selected = s.hz,
) { hz -> update(s.copy(hz = hz)) } ) { hz -> update(s.copy(hz = hz)) }
SettingDropdown( SettingDropdown(label = "Bitrate", options = BITRATE_OPTIONS, selected = s.bitrateKbps) { kbps ->
label = "Bitrate", update(s.copy(bitrateKbps = kbps))
options = BITRATE_OPTIONS, }
selected = s.bitrateKbps,
) { kbps -> update(s.copy(bitrateKbps = kbps)) }
SettingDropdown( SettingDropdown(label = "Video codec", options = CODEC_OPTIONS, selected = s.codec) { c ->
label = "Video codec", update(s.copy(codec = c))
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 // HDR is only meaningful on a panel that can present HDR10; on an SDR display the toggle is
// is disabled (and HDR is never advertised regardless) so the host doesn't send PQ the // disabled (and HDR is never advertised) so the host doesn't send PQ the panel mis-tone-maps.
// panel would mis-tone-map. The capability is fixed for the device, so read it once.
val hdrCapable = remember { displaySupportsHdr(context) } val hdrCapable = remember { displaySupportsHdr(context) }
ToggleRow( ToggleRow(
title = "HDR", title = "HDR",
@@ -121,69 +318,82 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
enabled = hdrCapable, enabled = hdrCapable,
onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) }, onCheckedChange = { on -> update(s.copy(hdrEnabled = on)) },
) )
}
SettingsGroup("Host") {
SettingDropdown( SettingDropdown(
label = "Compositor", label = "Compositor",
options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl }, options = COMPOSITOR_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.compositor, selected = s.compositor,
) { c -> update(s.copy(compositor = c)) } ) { c -> update(s.copy(compositor = c)) }
ToggleRow(
title = "Low-latency mode",
subtitle = "Run the decoder at max clocks for the lowest latency. Turn off only if a " +
"device overheats or glitches during long sessions.",
checked = s.lowLatencyMode,
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
)
}
}
@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( SettingDropdown(
label = "Controller type", label = "Controller type",
options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl }, options = GAMEPAD_OPTIONS.mapIndexed { i, lbl -> i to lbl },
selected = s.gamepad, selected = s.gamepad,
) { g -> update(s.copy(gamepad = g)) } ) { g -> update(s.copy(gamepad = g)) }
ClickableRow( ClickableRow(
title = "Connected controllers", title = "Connected controllers",
subtitle = "What the app detects, with a live input test", subtitle = "What the app detects, with a live input test",
onClick = { showControllers = true }, onClick = onOpenControllers,
) )
} }
}
SettingsGroup("Audio") { @Composable
SettingDropdown( private fun InterfaceSettings(s: Settings, update: (Settings) -> Unit) {
label = "Audio channels", SettingsCard {
options = AUDIO_CHANNEL_OPTIONS,
selected = s.audioChannels,
) { ch -> update(s.copy(audioChannels = ch)) }
ToggleRow( ToggleRow(
title = "Microphone", title = "Controller-optimized UI",
subtitle = "Send your mic to the host's virtual microphone", subtitle = "Switch to the console home (host carousel) when a controller is connected",
checked = s.micEnabled, checked = s.gamepadUiEnabled,
onCheckedChange = { on -> onCheckedChange = { on -> update(s.copy(gamepadUiEnabled = 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)
}
},
) )
} ToggleRow(
title = "Game library",
SettingsGroup("Touch input") { subtitle = "Browse a paired host's game library (press Y on a saved host)",
SettingDropdown( checked = s.libraryEnabled,
label = "Touch input", onCheckedChange = { on -> update(s.copy(libraryEnabled = on)) },
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( ToggleRow(
title = "Stats overlay", title = "Stats overlay",
subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)", subtitle = "Show FPS, throughput and latency while streaming (3-finger tap toggles it live)",
@@ -191,27 +401,22 @@ fun SettingsScreen(initial: Settings, onChange: (Settings) -> Unit, onBack: () -
onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) }, onCheckedChange = { on -> update(s.copy(statsHudEnabled = on)) },
) )
} }
}
SettingsGroup("About") { @Composable
private fun AboutSettings(onOpenLicenses: () -> Unit) {
SettingsCard {
ClickableRow( ClickableRow(
title = "Open-source licenses", title = "Open-source licenses",
subtitle = "Third-party notices and credits", subtitle = "Third-party notices and credits",
onClick = { showLicenses = true }, onClick = onOpenLicenses,
) )
} }
}
} }
/** A titled group of settings rendered inside an outlined card. */ /** A group of settings rendered inside an outlined card. */
@Composable @Composable
private fun SettingsGroup(title: String, content: @Composable ColumnScope.() -> Unit) { private fun SettingsCard(content: @Composable ColumnScope.() -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
Text(
title,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(start = 4.dp),
)
OutlinedCard(modifier = Modifier.fillMaxWidth()) { OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column( Column(
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
@@ -219,7 +424,6 @@ private fun SettingsGroup(title: String, content: @Composable ColumnScope.() ->
content = content, content = content,
) )
} }
}
} }
/** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */ /** A title + subtitle on the left, a Switch on the right. [enabled] greys out the whole row. */
@@ -265,6 +469,12 @@ private fun ClickableRow(title: String, subtitle: String, onClick: () -> Unit) {
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Icon(
Icons.AutoMirrored.Filled.KeyboardArrowRight,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(20.dp),
)
} }
} }
@@ -27,7 +27,7 @@ import kotlin.math.roundToInt
* older layouts just omit those lines. * older layouts just omit those lines.
*/ */
@Composable @Composable
internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) { internal fun StatsOverlay(s: DoubleArray, decoderLabel: String = "", modifier: Modifier = Modifier) {
if (s.size < 10) return if (s.size < 10) return
val w = s[6].toInt() val w = s[6].toInt()
val h = s[7].toInt() val h = s[7].toInt()
@@ -46,6 +46,14 @@ internal fun StatsOverlay(s: DoubleArray, modifier: Modifier = Modifier) {
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
fontSize = 12.sp, fontSize = 12.sp,
) )
if (decoderLabel.isNotEmpty()) {
Text(
decoderLabel,
color = Color(0xFFB0D0FF),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
)
}
videoFeedLine(s)?.let { feed -> videoFeedLine(s)?.let { feed ->
Text( Text(
feed, feed,
@@ -1,8 +1,11 @@
package io.unom.punktfunk package io.unom.punktfunk
import android.Manifest import android.Manifest
import android.content.Context
import android.content.pm.ActivityInfo import android.content.pm.ActivityInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.wifi.WifiManager
import android.os.Build
import android.view.SurfaceHolder import android.view.SurfaceHolder
import android.view.SurfaceView import android.view.SurfaceView
import android.view.WindowManager import android.view.WindowManager
@@ -30,6 +33,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import io.unom.punktfunk.kit.Gamepad import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadFeedback import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.NativeBridge import io.unom.punktfunk.kit.NativeBridge
import io.unom.punktfunk.kit.VideoDecoders
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -55,15 +59,23 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// comes from Settings. // comes from Settings.
val initialSettings = remember { SettingsStore(context).load() } val initialSettings = remember { SettingsStore(context).load() }
var stats by remember { mutableStateOf<DoubleArray?>(null) } var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) } var showStats by remember { mutableStateOf(initialSettings.statsHudEnabled) }
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes). // Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
val touchMode = initialSettings.touchMode val touchMode = initialSettings.touchMode
// Master low-latency toggle, resolved once for the session and passed to the decoder at start.
val lowLatencyMode = initialSettings.lowLatencyMode
// TV form factor (leanback): the decoder actively switches the HDMI output mode to the stream
// refresh; a phone/tablet gets the softer seamless frame-rate hint instead.
val isTv = remember { context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK) }
LaunchedEffect(handle, showStats) { LaunchedEffect(handle, showStats) {
NativeBridge.nativeSetVideoStatsEnabled(handle, showStats) NativeBridge.nativeSetVideoStatsEnabled(handle, showStats)
if (showStats) { if (showStats) {
while (true) { while (true) {
delay(1000) delay(1000)
stats = NativeBridge.nativeVideoStats(handle) stats = NativeBridge.nativeVideoStats(handle)
// The decoder is fixed for the session; fetch its label once it's resolved.
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
} }
} else { } else {
stats = null // drop the last snapshot so a re-show never flashes stale numbers stats = null // drop the last snapshot so a re-show never flashes stale numbers
@@ -76,8 +88,29 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit. // main thread, so a plain flag is race-free; AtomicBoolean just makes the intent explicit.
val closed = remember { AtomicBoolean(false) } val closed = remember { AtomicBoolean(false) }
// A Wi-Fi low-latency lock held for the stream's duration: asks the Wi-Fi firmware to drop its
// power-save polling (a common source of tens-of-ms jitter). WIFI_MODE_FULL_LOW_LATENCY (API
// 29+) is the strongest; older releases fall back to FULL_HIGH_PERF. Needs no extra permission
// beyond ACCESS_WIFI_STATE (already declared). Non-reference-counted: one explicit acquire/release.
val wifiLock = remember(handle) {
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as? WifiManager
val mode = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
WifiManager.WIFI_MODE_FULL_LOW_LATENCY
} else {
@Suppress("DEPRECATION")
WifiManager.WIFI_MODE_FULL_HIGH_PERF
}
wm?.createWifiLock(mode, "punktfunk:stream")?.apply { setReferenceCounted(false) }
}
DisposableEffect(handle) { DisposableEffect(handle) {
window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window?.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
runCatching { wifiLock?.acquire() }
// HDMI Auto Low-Latency Mode: ask the display to drop its post-processing (game mode) —
// the biggest panel-side latency win on the TV boxes. No-op where ALLM isn't supported. API 30+.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(true)
}
controller?.let { controller?.let {
it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE it.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
it.hide(WindowInsetsCompat.Type.systemBars()) it.hide(WindowInsetsCompat.Type.systemBars())
@@ -91,6 +124,8 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
activity?.streamHandle = handle // route hardware keys to this session activity?.streamHandle = handle // route hardware keys to this session
activity?.axisMapper = Gamepad.AxisMapper(handle) // route joystick axes 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. // Host→client feedback (rumble + DualSense lightbar/LEDs); poll threads stopped before close.
val feedback = GamepadFeedback(handle).also { it.start() } val feedback = GamepadFeedback(handle).also { it.start() }
onDispose { onDispose {
@@ -99,8 +134,14 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
activity?.axisMapper?.reset() // release-all so nothing sticks on the host activity?.axisMapper?.reset() // release-all so nothing sticks on the host
activity?.axisMapper = null activity?.axisMapper = null
activity?.streamHandle = 0L activity?.streamHandle = 0L
activity?.requestStreamExit = null
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
controller?.show(WindowInsetsCompat.Type.systemBars()) controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.setPreferMinimalPostProcessing(false)
}
runCatching { if (wifiLock?.isHeld == true) wifiLock.release() }
// Release the landscape lock so the rest of the app follows the device/system again. // Release the landscape lock so the rest of the app follows the device/system again.
activity?.requestedOrientation = activity?.requestedOrientation =
priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED priorOrientation ?: ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
@@ -121,7 +162,19 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
SurfaceView(ctx).apply { SurfaceView(ctx).apply {
holder.addCallback(object : SurfaceHolder.Callback { holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) { override fun surfaceCreated(holder: SurfaceHolder) {
NativeBridge.nativeStartVideo(handle, holder.surface) // Rank MediaCodecList decoders for the negotiated MIME (framework-only
// API) and hand the chosen one to Rust, which creates it by name and
// applies the per-SoC vendor low-latency keys.
val mime = NativeBridge.nativeVideoMime(handle)
val choice = VideoDecoders.pickDecoder(mime)
NativeBridge.nativeStartVideo(
handle,
holder.surface,
choice?.name ?: "",
lowLatencyMode,
choice?.lowLatencyFeature ?: false,
isTv,
)
NativeBridge.nativeStartAudio(handle) NativeBridge.nativeStartAudio(handle)
if (micWanted) NativeBridge.nativeStartMic(handle) if (micWanted) NativeBridge.nativeStartMic(handle)
} }
@@ -146,7 +199,7 @@ fun StreamScreen(handle: Long, micEnabled: Boolean, onDisconnect: () -> Unit) {
// Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but // Live stats HUD (FPS / throughput / capture→client latency), drawn over the video but
// BEFORE the transparent gesture layer below, so it shows through and never eats touches. // BEFORE the transparent gesture layer below, so it shows through and never eats touches.
if (showStats) { if (showStats) {
stats?.let { StatsOverlay(it, Modifier.align(Alignment.TopStart).padding(12.dp)) } stats?.let { StatsOverlay(it, decoderLabel, Modifier.align(Alignment.TopStart).padding(12.dp)) }
} }
// Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture // Touch input per the Settings model: trackpad/direct-pointer mouse (the shared gesture
// vocabulary) or real multi-touch passthrough — see TouchInput.kt. // vocabulary) or real multi-touch passthrough — see TouchInput.kt.
@@ -41,5 +41,7 @@ fun PunktfunkTheme(content: @Composable () -> Unit) {
} else { } else {
BrandDark 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, enabled: Boolean,
onConnect: () -> Unit, onConnect: () -> Unit,
onForget: (() -> Unit)?, onForget: (() -> Unit)?,
onRename: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
onWake: (() -> Unit)? = null, onWake: (() -> Unit)? = null,
) { ) {
// D-pad / controller focus highlight: a clickable card is focusable, but the default state // D-pad / controller focus highlight: a clickable card is focusable, but the default state
@@ -108,7 +108,7 @@ fun HostCard(
StatusPill(status) StatusPill(status)
} }
if (onForget != null || onRename != null || onWake != null) { if (onForget != null || onEdit != null || onWake != null) {
var menu by remember { mutableStateOf(false) } var menu by remember { mutableStateOf(false) }
Box(modifier = Modifier.align(Alignment.TopEnd)) { Box(modifier = Modifier.align(Alignment.TopEnd)) {
IconButton(enabled = enabled, onClick = { menu = true }) { IconButton(enabled = enabled, onClick = { menu = true }) {
@@ -129,12 +129,12 @@ fun HostCard(
}, },
) )
} }
if (onRename != null) { if (onEdit != null) {
DropdownMenuItem( DropdownMenuItem(
text = { Text("Rename") }, text = { Text("Edit…") },
onClick = { onClick = {
menu = false 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.
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
Game Mode config (Android 13 / API 33+). We support the Performance game mode; and we opt OUT of
the two OEM interventions that would corrupt a game-streaming session:
- allowGameDownscaling=false: the client renders exactly the host's negotiated resolution; a
platform downscale would blur the stream.
- allowGameFpsOverride=false: the stream is paced to the host's frame rate; a platform FPS cap
would drop frames / add judder.
Ignored on releases below API 33.
-->
<game-mode-config xmlns:android="http://schemas.android.com/apk/res/android"
android:supportsPerformanceGameMode="true"
android:allowGameDownscaling="false"
android:allowGameFpsOverride="false" />
@@ -83,7 +83,7 @@ internal fun HostsScene() {
} }
item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") } item(span = { GridItemSpan(maxLineSpan) }) { SectionLabel("Saved hosts") }
items(SAVED) { h -> 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) }) { item(span = { GridItemSpan(maxLineSpan) }) {
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
+12 -5
View File
@@ -15,8 +15,10 @@ android {
ndkVersion = ndkVer ndkVersion = ndkVer
defaultConfig { defaultConfig {
minSdk = 31 minSdk = 28 // Android 9 — reaches older TV boxes; API 31+ features are runtime-gated.
ndk { abiFilters += listOf("arm64-v8a", "x86_64") } // 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 { compileOptions {
sourceCompatibility = JavaVersion.VERSION_21 sourceCompatibility = JavaVersion.VERSION_21
@@ -28,6 +30,9 @@ android {
kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } } kotlin { compilerOptions { jvmTarget.set(JvmTarget.JVM_21) } }
dependencies { 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 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. // find their subtools.
val cmd = mutableListOf( val cmd = mutableListOf(
"$cargoBin/cargo", "ndk", "$cargoBin/cargo", "ndk",
"-t", "arm64-v8a", "-t", "x86_64", "-t", "arm64-v8a", "-t", "armeabi-v7a", "-t", "x86_64",
// Link against the minSdk-31 sysroot so libaaudio (API 26+) is found. // Link against the minSdk-28 sysroot: libaaudio (API 26) is present, and building at the
"--platform", "31", // 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, "-o", file("src/main/jniLibs").absolutePath,
"build", "-p", "punktfunk-client-android", "build", "-p", "punktfunk-client-android",
) )
@@ -8,6 +8,7 @@ import android.hardware.lights.LightsRequest
import android.os.Build import android.os.Build
import android.os.CombinedVibration import android.os.CombinedVibration
import android.os.VibrationEffect import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager import android.os.VibratorManager
import android.util.Log import android.util.Log
import android.view.InputDevice 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 * 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 * 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). * 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 * 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 hidoutThread: Thread? = null
private var vm: VibratorManager? = 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 vibratorIds: IntArray = IntArray(0)
private var amplitudeControlled = false private var amplitudeControlled = false
@@ -81,6 +86,7 @@ class GamepadFeedback(private val handle: Long) {
rumbleThread?.interrupt() rumbleThread?.interrupt()
hidoutThread?.interrupt() hidoutThread?.interrupt()
runCatching { vm?.cancel() } // drop any held rumble immediately 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 // 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 // pull (nativeNextRumble/nativeNextHidout), so they MUST be dead before StreamScreen's
// onDispose reaches nativeClose, which frees that handle. A *bounded* join that times out // 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 rgbLight = null
playerLight = null playerLight = null
vm = null vm = null
legacy = null
vibratorIds = IntArray(0) vibratorIds = IntArray(0)
} }
@@ -111,6 +118,7 @@ class GamepadFeedback(private val handle: Long) {
Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)") Log.i(TAG, "rumble: no controller connected — rumble no-op (emulator path)")
return return
} }
if (Build.VERSION.SDK_INT >= 31) {
val m = dev.vibratorManager val m = dev.vibratorManager
val ids = m.vibratorIds val ids = m.vibratorIds
if (ids.isEmpty()) { if (ids.isEmpty()) {
@@ -121,14 +129,27 @@ class GamepadFeedback(private val handle: Long) {
vibratorIds = ids vibratorIds = ids
amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() } amplitudeControlled = ids.all { m.getVibrator(it).hasAmplitudeControl() }
Log.i(TAG, "rumble: bound ${ids.size} vibrators amplitudeControl=$amplitudeControlled") 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")
}
} }
/** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */ /** low = heavy/left motor, high = light/right motor; both 0..0xFFFF (the host's u16 amplitudes). */
private fun renderRumble(low: Int, high: Int) { private fun renderRumble(low: Int, high: Int) {
Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return Log.i(TAG, "rumble low=$low high=$high") // verification line — BEFORE any no-op return
val m = vm ?: return
val lo = toAmplitude(low) val lo = toAmplitude(low)
val hi = toAmplitude(high) val hi = toAmplitude(high)
val m = vm
if (m != null) {
if (lo == 0 && hi == 0) { if (lo == 0 && hi == 0) {
m.cancel() // (0,0) = stop m.cancel() // (0,0) = stop
return return
@@ -144,6 +165,18 @@ class GamepadFeedback(private val handle: Long) {
for (id in vibratorIds) combo.addVibrator(id, oneShot(a)) for (id in vibratorIds) combo.addVibrator(id, oneShot(a))
} }
runCatching { m.vibrate(combo.combine()) } runCatching { m.vibrate(combo.combine()) }
return
}
// 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))
}
} }
// 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0. // 0..0xFFFF → 1..255 (high byte); a nonzero motor never collapses to 0.
@@ -104,14 +104,40 @@ object NativeBridge {
external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean external fun nativeWakeOnLan(macsCsv: String, lastIp: String): Boolean
/** /**
* Start the HEVC decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs * The MediaCodec MIME the host resolved for this session (`"video/hevc"` / `"video/avc"` /
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. No-op if already started. * `"video/av01"`), or `""` on a `0` handle. Kotlin ranks `MediaCodecList` decoders for this
* MIME (see [io.unom.punktfunk.kit.VideoDecoders]) before [nativeStartVideo]. Cheap; UI-safe.
*/ */
external fun nativeStartVideo(handle: Long, surface: android.view.Surface) external fun nativeVideoMime(handle: Long): String
/**
* Start the decode thread rendering onto [surface] (a SurfaceView's surface). Decode runs
* entirely in Rust (NDK AMediaCodec → ANativeWindow) — no per-frame JNI. [decoderName] is the
* decoder Kotlin ranked from `MediaCodecList` (`""` = let the platform resolve the default for
* the MIME); [lowLatencyMode] is the user's master toggle (default on → aggressive per-SoC
* tuning; off → conservative); [lowLatencyFeature] is whether [decoderName] advertised
* `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI mode switch to the stream
* refresh on TV boxes (vs. the softer seamless hint on phones). No-op if already started.
*/
external fun nativeStartVideo(
handle: Long,
surface: android.view.Surface,
decoderName: String,
lowLatencyMode: Boolean,
lowLatencyFeature: Boolean,
isTv: Boolean,
)
/** Stop + join the decode thread without closing the session. No-op on `0`. */ /** Stop + join the decode thread without closing the session. No-op on `0`. */
external fun nativeStopVideo(handle: Long) external fun nativeStopVideo(handle: Long)
/**
* The resolved decoder identity for the HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""`
* before the decode thread has resolved one. One-shot (fixed for the session); poll once after
* the HUD appears.
*/
external fun nativeVideoDecoderLabel(handle: Long): String
/** /**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs. * Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 18 doubles (unified stats spec, `design/stats-unification.md`): * Returns 18 doubles (unified stats spec, `design/stats-unification.md`):
@@ -0,0 +1,85 @@
package io.unom.punktfunk.kit
import android.media.MediaCodecInfo.CodecCapabilities
import android.media.MediaCodecList
import android.os.Build
/** The decoder Kotlin ranked for a MIME, handed to [NativeBridge.nativeStartVideo]. */
data class DecoderChoice(val name: String, val lowLatencyFeature: Boolean)
/**
* Rank the platform's `MediaCodecList` decoders for a video MIME and pick the best one for
* low-latency streaming, the way Moonlight-Android does. There is no NDK `MediaCodecList`, so this
* enumeration must live on the Kotlin (framework) side; Rust then creates the chosen decoder by
* name (`AMediaCodec_createCodecByName`) and derives the per-SoC vendor low-latency keys from it.
*
* Ranking (best first): hardware over software; a real SoC-vendor decoder (Qualcomm/Amlogic/…) over
* the generic AOSP software fallback; a decoder advertising `FEATURE_LowLatency` over one that
* doesn't. Known-bad software decoders (`omx.google.*`, `c2.android.*`, Qualcomm/Samsung SW HEVC)
* are dropped outright — matching Moonlight's blacklist.
*/
object VideoDecoders {
/** Decoder-name prefixes/names we never want, mirroring Moonlight's blacklist. */
private val BLOCKED_PREFIXES = listOf("omx.google.", "c2.android.", "avcdecoder", "omx.ffmpeg.")
private val BLOCKED_EXACT = listOf("omx.qcom.video.decoder.hevcswvdec", "omx.sec.hevc.sw.dec")
/**
* Real SoC-vendor decoder prefixes we prefer over the generic AOSP fallback, covering the common
* targets: Qualcomm Snapdragon and MediaTek (most phones + many TV boxes), Samsung Exynos (+
* Google Tensor, whose decoder is `c2.exynos.*`), NVIDIA Tegra (Shield TV), Amlogic / Rockchip /
* Realtek (TV boxes & smart TVs), and HiSilicon Kirin (older Huawei).
*/
private val VENDOR_PREFIXES = listOf(
"omx.qcom", "c2.qti",
"omx.mtk", "c2.mtk",
"omx.exynos", "c2.exynos",
"omx.nvidia", "c2.nvidia",
"omx.amlogic", "c2.amlogic",
"omx.rk", "c2.rk",
"omx.realtek", "c2.realtek",
"omx.hisi", "c2.hisi",
)
/**
* Pick the best decoder for [mime] (`"video/hevc"` / `"video/avc"` / `"video/av01"`), or `null`
* to let the platform resolve its default. Enumerates once — call at stream start.
*/
fun pickDecoder(mime: String): DecoderChoice? {
if (mime.isEmpty()) return null
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
.getOrNull() ?: return null
var bestName: String? = null
var bestLowLatency = false
var bestScore = Int.MIN_VALUE
for (info in infos) {
if (info.isEncoder) continue
val name = info.name
val lower = name.lowercase()
if (BLOCKED_PREFIXES.any { lower.startsWith(it) } || lower in BLOCKED_EXACT) continue
val caps = runCatching { info.getCapabilitiesForType(mime) }.getOrNull() ?: continue
val hardware = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
info.isHardwareAccelerated
} else {
// Pre-Q heuristic: the software decoders are the ones we can name (already blocked
// above), so anything surviving the blacklist is treated as hardware.
true
}
val lowLatency = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R &&
runCatching { caps.isFeatureSupported(CodecCapabilities.FEATURE_LowLatency) }
.getOrDefault(false)
val vendor = VENDOR_PREFIXES.any { lower.startsWith(it) }
val score = (if (hardware) 100 else 0) +
(if (vendor) 40 else 0) +
(if (lowLatency) 20 else 0)
if (score > bestScore) {
bestScore = score
bestName = name
bestLowLatency = lowLatency
}
}
return bestName?.let { DecoderChoice(it, bestLowLatency) }
}
}
@@ -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)) 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. */ /** All trusted hosts, name-sorted — backs the saved-hosts list. */
fun all(): List<KnownHost> = fun all(): List<KnownHost> =
prefs.all.values.mapNotNull { (it as? String)?.let(::parse) }.sortedBy { it.name.lowercase() } 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() }, mac = j.optString("mac", "").split(",").map { it.trim() }.filter { it.isNotEmpty() },
) )
}.getOrNull() }.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). # NDK bindings. "media" = AMediaCodec/ANativeWindow (video); "audio" = AAudio (audio playback).
# Pure-Rust FFI to libmediandk/libnativewindow/libaaudio — no C++/libc++_shared to bundle. Decode + # 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). # 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). # setpriority/gettid to raise the decode thread toward URGENT_DISPLAY (see decode::boost_thread_priority).
libc = "0.2" libc = "0.2"
# Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the # Opus decode for the host→client audio plane (0xC9: 48 kHz stereo, 5 ms frames). Same crate the
+20
View File
@@ -28,6 +28,7 @@ type CreateSessionFn = unsafe extern "C" fn(*mut c_void, *const i32, usize, i64)
type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int; type ReportFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int; type UpdateTargetFn = unsafe extern "C" fn(*mut c_void, i64) -> c_int;
type CloseFn = unsafe extern "C" fn(*mut c_void); type CloseFn = unsafe extern "C" fn(*mut c_void);
type SetPreferPowerEfficiencyFn = unsafe extern "C" fn(*mut c_void, bool) -> c_int;
/// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager. /// The entry points we use, resolved once from `libandroid.so`, plus the process-wide manager.
struct Api { struct Api {
@@ -35,6 +36,9 @@ struct Api {
report: ReportFn, report: ReportFn,
update_target: UpdateTargetFn, update_target: UpdateTargetFn,
close: CloseFn, close: CloseFn,
/// `APerformanceHint_setPreferPowerEfficiency` — NDK **API 35**, so `Option`al even when the
/// rest of ADPF resolved (a 33/34 device has the session API but not this one).
set_prefer_power_efficiency: Option<SetPreferPowerEfficiencyFn>,
manager: *mut c_void, manager: *mut c_void,
} }
@@ -70,11 +74,20 @@ fn resolve_api() -> Option<Api> {
if manager.is_null() { if manager.is_null() {
return None; return None;
} }
// Optional (API 35): resolve if present, else `None` — the session still works without it.
let set_prefer_power_efficiency =
libc::dlsym(lib, c"APerformanceHint_setPreferPowerEfficiency".as_ptr());
let set_prefer_power_efficiency = (!set_prefer_power_efficiency.is_null()).then(|| {
std::mem::transmute::<*mut c_void, SetPreferPowerEfficiencyFn>(
set_prefer_power_efficiency,
)
});
Some(Api { Some(Api {
create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session), create_session: std::mem::transmute::<*mut c_void, CreateSessionFn>(create_session),
report: std::mem::transmute::<*mut c_void, ReportFn>(report), report: std::mem::transmute::<*mut c_void, ReportFn>(report),
update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target), update_target: std::mem::transmute::<*mut c_void, UpdateTargetFn>(update_target),
close: std::mem::transmute::<*mut c_void, CloseFn>(close), close: std::mem::transmute::<*mut c_void, CloseFn>(close),
set_prefer_power_efficiency,
manager, manager,
}) })
} }
@@ -103,6 +116,13 @@ impl HintSession {
if session.is_null() { if session.is_null() {
return None; return None;
} }
// Tell the governor NOT to bias this session toward power efficiency (API 35+): our loop is
// latency-critical, so we want it kept on fast cores at high clocks over battery savings.
// Best-effort; absent below API 35.
if let Some(f) = api.set_prefer_power_efficiency {
// SAFETY: `session` is the live session just created; the fn takes it + a bool.
unsafe { f(session, false) };
}
Some(Self { api, session }) Some(Self { api, session })
} }
+7 -2
View File
@@ -18,8 +18,8 @@
//! grown on XRuns (Google's anti-glitch technique). //! grown on XRuns (Google's anti-glitch technique).
use ndk::audio::{ use ndk::audio::{
AudioCallbackResult, AudioDirection, AudioFormat, AudioPerformanceMode, AudioSharingMode, AudioCallbackResult, AudioContentType, AudioDirection, AudioFormat, AudioPerformanceMode,
AudioStream, AudioStreamBuilder, AudioSharingMode, AudioStream, AudioStreamBuilder, AudioUsage,
}; };
use punktfunk_core::client::NativeClient; use punktfunk_core::client::NativeClient;
use punktfunk_core::error::PunktfunkError; use punktfunk_core::error::PunktfunkError;
@@ -235,6 +235,11 @@ impl AudioPlayback {
// captures + Opus-encodes in exactly this order. // captures + Opus-encodes in exactly this order.
.channel_count(channels as i32) .channel_count(channels as i32)
.format(AudioFormat::PCM_Float) .format(AudioFormat::PCM_Float)
// Tag the stream as game audio (usage=Game / content=Movie): the audio HAL applies
// its low-latency game-audio routing/policy and it's grouped correctly with the
// game-mode profile. Advisory — ignored where the device has no such policy.
.usage(AudioUsage::Game)
.content_type(AudioContentType::Movie)
.performance_mode(AudioPerformanceMode::LowLatency) .performance_mode(AudioPerformanceMode::LowLatency)
.sharing_mode(sharing) .sharing_mode(sharing)
.data_callback(Box::new(callback)) .data_callback(Box::new(callback))
+751 -36
View File
@@ -8,19 +8,25 @@
use ndk::data_space::DataSpace; use ndk::data_space::DataSpace;
use ndk::media::media_codec::{ use ndk::media::media_codec::{
DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec, MediaCodecDirection, AsyncNotifyCallback, DequeuedInputBufferResult, DequeuedOutputBufferInfoResult, MediaCodec,
OutputBuffer, MediaCodecDirection, OutputBuffer,
}; };
use ndk::media::media_format::MediaFormat; 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::client::NativeClient;
use punktfunk_core::error::PunktfunkError; use punktfunk_core::error::PunktfunkError;
use punktfunk_core::session::Frame; use punktfunk_core::session::Frame;
use std::collections::VecDeque; use std::collections::VecDeque;
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::{mpsc, Arc, Mutex};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Cap on AUs parked in the async loop awaiting a free codec input slot. Matches the connector's
/// own frame-channel depth; on sustained overflow the oldest is dropped and a keyframe requested
/// (same recovery as a reassembler drop). In steady state this stays near-empty.
const FRAME_PARK_CAP: usize = 16;
/// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in /// Cap on the pts→received-timestamp map below: MediaCodec holds only a handful of frames in
/// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted. /// flight, so anything beyond this is stale (codec flushed / HUD toggled) and gets evicted.
const IN_FLIGHT_CAP: usize = 64; const IN_FLIGHT_CAP: usize = 64;
@@ -30,29 +36,80 @@ const IN_FLIGHT_CAP: usize = 64;
/// this deep is a lost datagram (or an old host that never sends any) and gets evicted. /// this deep is a lost datagram (or an old host that never sends any) and gets evicted.
const PENDING_SPLIT_CAP: usize = 256; const PENDING_SPLIT_CAP: usize = 256;
/// The decode loop. Runs on the `pf-decode` thread until `shutdown` is set or the session closes. /// Whether to run the event-driven async decode loop (default) or the synchronous poll loop kept as
/// a bring-up fallback. Flip to `false` to A/B the two on the HUD (`design/…`); the async loop
/// presents a decoded frame the instant it's ready instead of waiting out a poll interval.
const USE_ASYNC_DECODE: bool = true;
/// Per-session decode configuration, resolved by the JNI layer (`nativeStartVideo`) and passed to
/// the decode loop. Bundled so the loop entry points don't sprout a wide argument list.
pub(crate) struct DecodeOptions {
/// The decoder Kotlin ranked from `MediaCodecList` (`VideoDecoders.pickDecoder`). `None`/empty ⇒
/// let the platform resolve the default decoder for the MIME.
pub decoder_name: Option<String>,
/// Whether Kotlin found the chosen decoder advertises `FEATURE_LowLatency` (queryable only via
/// the Java `CodecCapabilities` API) — surfaced on the HUD next to the decoder name.
pub ll_feature: bool,
/// The user's "Low-latency mode" master toggle (default on ⇒ full aggressive profile; off ⇒
/// conservative, an escape hatch for a device that throttles under the clocks).
pub low_latency_mode: bool,
/// TV form factor (Kotlin's `UiModeManager`): actively drive the HDMI output into the stream's
/// refresh mode, vs. the softer seamless hint on a phone/tablet.
pub is_tv: bool,
}
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
/// Both run until `shutdown` is set or the session closes.
pub fn run( pub fn run(
client: Arc<NativeClient>, client: Arc<NativeClient>,
window: NativeWindow, window: NativeWindow,
shutdown: Arc<AtomicBool>, shutdown: Arc<AtomicBool>,
stats: Arc<crate::stats::VideoStats>, stats: Arc<crate::stats::VideoStats>,
opts: DecodeOptions,
) { ) {
if USE_ASYNC_DECODE {
run_async(client, window, shutdown, stats, opts);
} else {
run_sync(client, window, shutdown, stats, opts);
}
}
/// The synchronous poll loop — the original decode path, kept as a bring-up fallback behind
/// [`USE_ASYNC_DECODE`]. Feeds and drains on this one thread; the only blocking wait is a short
/// output dequeue while input is backed up.
#[allow(dead_code)]
fn run_sync(
client: Arc<NativeClient>,
window: NativeWindow,
shutdown: Arc<AtomicBool>,
stats: Arc<crate::stats::VideoStats>,
opts: DecodeOptions,
) {
let DecodeOptions {
decoder_name,
ll_feature,
low_latency_mode,
is_tv,
} = opts;
boost_thread_priority(); boost_thread_priority();
let mode = client.mode(); let mode = client.mode();
// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`): HEVC or H.264. AMediaCodec // The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). AMediaCodec needs no
// needs no out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way. // out-of-band extradata — the in-band VPS/SPS/PPS on every IDR configure it either way.
let mime = match client.codec { let mime = codec_mime(client.codec);
punktfunk_core::quic::CODEC_H264 => "video/avc", let codec = match create_codec(mime, decoder_name.as_deref()) {
_ => "video/hevc",
};
let codec = match MediaCodec::from_decoder_type(mime) {
Some(c) => c, Some(c) => c,
None => { None => {
log::error!("decode: no {mime} decoder on this device"); log::error!("decode: no {mime} decoder on this device");
return; return;
} }
}; };
log::info!("decode: codec mime = {mime}"); // The decoder's *actual* resolved name (Kotlin's pick, or the platform default when it fell
// back) drives both the HUD label and which vendor low-latency keys apply below.
let codec_name = codec.name().unwrap_or_default();
stats.set_decoder(&codec_name, ll_feature);
log::info!(
"decode: codec mime = {mime}, decoder = {codec_name} (low-latency feature: {ll_feature})"
);
let mut format = MediaFormat::new(); let mut format = MediaFormat::new();
format.set_str("mime", mime); format.set_str("mime", mime);
@@ -63,23 +120,9 @@ pub fn run(
"max-input-size", "max-input-size",
(mode.width * mode.height).max(2_000_000) as i32, (mode.width * mode.height).max(2_000_000) as i32,
); );
// Ask for the low-latency decode path where the decoder supports it (no reordering buffer). // Standard + per-SoC vendor low-latency keys and the clock hints, gated on the resolved decoder
format.set_i32("low-latency", 1); // name and the master toggle (see `configure_low_latency`).
// Best-effort vendor twin of the standard key: older Qualcomm decoders only honor their own configure_low_latency(&mut format, &codec_name, low_latency_mode);
// extension. Unknown keys are ignored by other vendors' codecs, so this is safe to set blind.
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
// Advisory low-latency hints (KEY_PRIORITY / KEY_OPERATING_RATE), ignored where unsupported:
// realtime priority + the target frame rate, so vendor decoders (e.g. Qualcomm) run at full
// clocks instead of a power-saving cadence that adds dequeue latency.
format.set_i32("priority", 0); // 0 = realtime
// Operating rate = the codec's clock hint. Setting it to the display rate merely asks the
// decoder to *sustain* that cadence — a Qualcomm decoder can meet 60/120 fps at a power-saving
// clock that adds a millisecond-plus of decode latency per frame. Setting it to the AOSP
// "unbounded" sentinel (Short.MAX) instead asks the decoder to run each frame at max clocks and
// finish ASAP, minimising per-frame decode latency — the right trade for a real-time stream
// (costs power/heat; the dial to lower if a device thermally throttles over a long session).
// Ignored where unsupported.
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
// HDR static metadata (ST.2086 mastering + content light level): when an HDR session was // HDR static metadata (ST.2086 mastering + content light level): when an HDR session was
// negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade. // negotiated, set KEY_HDR_STATIC_INFO so the display tone-maps from the source's real grade.
@@ -113,11 +156,13 @@ pub fn run(
mode.height mode.height
); );
// Tell the display the stream's refresh so Android can pick a matching display mode and align // 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 // vsync (no 60-in-120 judder on high-refresh panels). `ANativeWindow_setFrameRate` is NDK API 30,
// ANativeWindow_setFrameRate is always present; non-fatal if the platform declines. // above our API-28 floor, so we resolve it at runtime (see `try_set_frame_rate`) rather than link
if let Err(e) = window.set_frame_rate(mode.refresh_hz as f32, FrameRateCompatibility::Default) { // it — a hard import would stop `libpunktfunk_android.so` loading at all on API 28/29. Absent
log::warn!( // there ⇒ we simply skip the hint (non-fatal; the stream renders fine without it).
"decode: set_frame_rate({} Hz) failed (non-fatal): {e}", if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) {
log::debug!(
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
mode.refresh_hz mode.refresh_hz
); );
} }
@@ -274,6 +319,7 @@ pub fn run(
// or where the platform declines → `None`, and the loop runs unhinted). // or where the platform declines → `None`, and the loop runs unhinted).
hint_tried = true; hint_tried = true;
let tids = client.hot_thread_ids(); let tids = client.hot_thread_ids();
boost_hot_threads(&tids);
hint = crate::adpf::HintSession::create(frame_period_ns, &tids); hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
log::info!( log::info!(
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns", "decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
@@ -323,6 +369,609 @@ fn now_realtime_ns() -> i128 {
.unwrap_or(0) .unwrap_or(0)
} }
/// The MediaCodec MIME for the codec the host resolved (`Welcome.codec`). Shared by the decode
/// thread and `nativeVideoMime` (which tells Kotlin what to rank decoders for). AV1 uses the
/// AOSP `video/av01` type; anything not H.264/AV1 is treated as HEVC (every pre-negotiation host
/// emitted HEVC).
pub(crate) fn codec_mime(codec: u8) -> &'static str {
match codec {
punktfunk_core::quic::CODEC_H264 => "video/avc",
punktfunk_core::quic::CODEC_AV1 => "video/av01",
_ => "video/hevc",
}
}
/// Create the decoder: prefer the specific codec Kotlin ranked from `MediaCodecList`
/// (`from_codec_name`), falling back to the platform's default decoder for the MIME
/// (`from_decoder_type`) if that name can't be created (codec busy / renamed across an OS update).
fn create_codec(mime: &str, preferred: Option<&str>) -> Option<MediaCodec> {
if let Some(name) = preferred.filter(|n| !n.is_empty()) {
if let Some(c) = MediaCodec::from_codec_name(name) {
return Some(c);
}
log::warn!(
"decode: from_codec_name({name}) failed — falling back to default {mime} decoder"
);
}
MediaCodec::from_decoder_type(mime)
}
/// Apply the low-latency MediaFormat keys for `codec_name`. The standard AOSP `low-latency` key is
/// always set (API 30+, harmless/ignored elsewhere). When `aggressive` (the "Low-latency mode"
/// master toggle) we additionally set MediaTek's `vdec-lowlatency` (unconditionally — ignored off
/// MediaTek), the per-SoC vendor extension keys (gated on the decoder-name prefix the way
/// Moonlight-Android does, since a key one vendor honours is meaningless on another), and one clock
/// hint. Off ⇒ the standard key only, a gentler profile for a device that throttles under max clocks.
///
/// Vendor keys mirror Moonlight's `MediaCodecHelper` (verified against current source): Qualcomm
/// picture-order + low-latency, Exynos (also Google Tensor), Amlogic, HiSilicon, MediaTek. NVIDIA
/// Tegra / Rockchip / Realtek expose no such key (nor does Moonlight) — they're covered by the
/// standard key + clock hint + being ranked first in `VideoDecoders`.
fn configure_low_latency(format: &mut MediaFormat, codec_name: &str, aggressive: bool) {
// Standard key: request the no-reorder low-latency path where the platform decoder supports it.
format.set_i32("low-latency", 1);
if !aggressive {
return;
}
// MediaTek's low-latency key — very common (mid/budget phones + many Google TV / Fire TV boxes).
// Set unconditionally like the standard key: MediaTek decoders honour it, others ignore it, so it
// covers MediaTek whatever the exact decoder name (omx.mtk / c2.mtk / an OEM rename). Moonlight
// does the same, and also relies on it for Amazon's Amlogic fork.
format.set_i32("vdec-lowlatency", 1);
let name = codec_name.to_ascii_lowercase();
let is = |prefix: &str| name.starts_with(prefix);
// Qualcomm Snapdragon (the most common phone SoC): picture-order forces decode-order output
// (kills the reorder buffer on decoders that predate the standard key); low-latency is the older
// vendor twin.
if is("omx.qcom") || is("c2.qti") {
format.set_i32("vendor.qti-ext-dec-picture-order.enable", 1);
format.set_i32("vendor.qti-ext-dec-low-latency.enable", 1);
}
// Samsung Exynos — also covers Google Tensor (Pixel 6+), whose hardware decoder is `c2.exynos.*`.
if is("omx.exynos") || is("c2.exynos") {
format.set_i32("vendor.rtc-ext-dec-low-latency.enable", 1);
}
// Amlogic — the Android TV boxes (onn 4K, Chromecast w/ Google TV, Homatics).
if is("omx.amlogic") || is("c2.amlogic") {
format.set_i32("vendor.low-latency.enable", 1);
}
// HiSilicon / Kirin (older Huawei; paired req/rdy keys).
if is("omx.hisi") || is("c2.hisi") {
format.set_i32(
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-req",
1,
);
format.set_i32(
"vendor.hisi-ext-low-latency-video-dec.video-scene-for-low-latency-rdy",
-1,
);
}
// NVIDIA Tegra (Shield TV) and Rockchip/Realtek (budget TV boxes / smart TVs) expose no
// low-latency vendor key (Moonlight has none either) — their decoders are already low-latency
// oriented, so the standard `low-latency` key + the clock hint below + being ranked first
// (see `VideoDecoders`) is their treatment.
//
// Clock hint, mutually exclusive (matching Moonlight): the AOSP "unbounded" operating-rate
// sentinel (Short.MAX) tells the decoder to run each frame at max clocks and finish ASAP rather
// than pace to the frame rate — shaving per-frame decode latency at a power/heat cost. Only
// Qualcomm is known to handle the sentinel; every other vendor mis-paces on it, so they get the
// plain realtime `priority` hint instead.
if decoder_supports_max_operating_rate(&name) {
format.set_i32("operating-rate", i16::MAX as i32); // 32767 = "as fast as possible"
} else {
format.set_i32("priority", 0); // 0 = realtime
}
}
/// Whether a decoder tolerates `operating-rate = Short.MAX` rather than regressing on it. Follows
/// Moonlight's allowlist: Qualcomm decoders honour the sentinel (the Adreno 620 generation is the
/// known exception Moonlight excludes by GPU model — undetectable from native code here, so it
/// rides the master toggle as its escape hatch). Other vendors fall back to the plain `priority`
/// hint above.
fn decoder_supports_max_operating_rate(name_lower: &str) -> bool {
name_lower.starts_with("omx.qcom") || name_lower.starts_with("c2.qti")
}
/// One decoded output buffer ready to release: its codec buffer index + the pts the codec echoed
/// (from the output callback's `BufferInfo`), used to pair the `decode` HUD stat.
struct OutputReady {
index: usize,
pts_us: u64,
}
/// Events the async decode loop reacts to. The codec's async-notify callbacks (which run on its
/// internal looper thread) push the codec ones; the feeder thread pushes `Au`. Each carries only
/// owned/`Copy` data so the callback closures satisfy the `Send` bound and never touch the codec.
enum DecodeEvent {
/// A received access unit from the feeder, ready to queue into the decoder.
Au(Frame),
/// An input buffer slot freed (index) — we can queue an AU into it.
InputAvailable(usize),
/// A decoded frame is ready (buffer index + echoed pts).
OutputAvailable { index: usize, pts_us: u64 },
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
FormatChanged,
/// The codec reported an error; `fatal` when neither recoverable nor transient.
Error { fatal: bool },
}
/// The event-driven async decode loop (default; see [`run`]/[`USE_ASYNC_DECODE`]). The codec drives
/// us: an async-notify callback fires the instant an input buffer frees or a frame finishes
/// decoding, so a decoded frame is presented immediately instead of waiting out a poll interval (the
/// latency the sync loop left on the table). The callbacks run on the codec's internal looper thread
/// and only *push events* — every `AMediaCodec` buffer op stays on this thread, which owns the codec,
/// sidestepping the self-reference that would arise from a callback calling back into the codec it's
/// stored in. A small `pf-decode-feed` thread blocks on the network so this loop never does.
fn run_async(
client: Arc<NativeClient>,
window: NativeWindow,
shutdown: Arc<AtomicBool>,
stats: Arc<crate::stats::VideoStats>,
opts: DecodeOptions,
) {
let DecodeOptions {
decoder_name,
ll_feature,
low_latency_mode,
is_tv,
} = opts;
boost_thread_priority();
let mode = client.mode();
let mime = codec_mime(client.codec);
let mut codec = match create_codec(mime, decoder_name.as_deref()) {
Some(c) => c,
None => {
log::error!("decode: no {mime} decoder on this device");
return;
}
};
let codec_name = codec.name().unwrap_or_default();
stats.set_decoder(&codec_name, ll_feature);
log::info!(
"decode: codec mime = {mime}, decoder = {codec_name} (async, low-latency feature: {ll_feature})"
);
// The event channel: the callbacks + feeder push, this loop pulls. `Sender` is `Send`, so the
// callback closures (each capturing a clone) satisfy the async-notify `Send` bound.
let (ev_tx, ev_rx) = mpsc::channel::<DecodeEvent>();
// Install the callbacks BEFORE configure()/start() so we're in async mode from the first buffer.
// Each just forwards an index/flag — no codec access here (the codec owns these closures).
{
let out_tx = ev_tx.clone();
let in_tx = ev_tx.clone();
let fmt_tx = ev_tx.clone();
let err_tx = ev_tx.clone();
let cb = AsyncNotifyCallback {
on_input_available: Some(Box::new(move |idx| {
let _ = in_tx.send(DecodeEvent::InputAvailable(idx));
})),
on_output_available: Some(Box::new(move |idx, info| {
let _ = out_tx.send(DecodeEvent::OutputAvailable {
index: idx,
pts_us: info.presentation_time_us().max(0) as u64,
});
})),
on_format_changed: Some(Box::new(move |_fmt| {
let _ = fmt_tx.send(DecodeEvent::FormatChanged);
})),
on_error: Some(Box::new(move |e, code, _detail| {
let fatal = !code.is_recoverable() && !code.is_transient();
log::warn!("decode: codec error {e:?} (fatal={fatal})");
let _ = err_tx.send(DecodeEvent::Error { fatal });
})),
};
if let Err(e) = codec.set_async_notify_callback(Some(cb)) {
log::error!("decode: set_async_notify_callback failed: {e}");
return;
}
}
// Build the low-latency format (identical keys to the sync path).
let mut format = MediaFormat::new();
format.set_str("mime", mime);
format.set_i32("width", mode.width as i32);
format.set_i32("height", mode.height as i32);
format.set_i32(
"max-input-size",
(mode.width * mode.height).max(2_000_000) as i32,
);
configure_low_latency(&mut format, &codec_name, low_latency_mode);
if client.color.is_hdr() {
match client.next_hdr_meta(Duration::from_millis(250)) {
Ok(meta) => {
format.set_buffer("hdr-static-info", &android_hdr_static_info(&meta));
log::info!("decode: HDR static metadata applied (KEY_HDR_STATIC_INFO)");
}
Err(_) => {
log::info!("decode: HDR session but no mastering metadata yet — DataSpace only")
}
}
}
if let Err(e) = codec.configure(&format, Some(&window), MediaCodecDirection::Decoder) {
log::error!("decode: configure failed: {e}");
return;
}
if let Err(e) = codec.start() {
log::error!("decode: start failed: {e}");
return;
}
log::info!(
"decode: decoder started (async) at {}x{}",
mode.width,
mode.height
);
if mode.refresh_hz > 0 && !try_set_frame_rate(&window, mode.refresh_hz as f32, is_tv) {
log::debug!(
"decode: set_frame_rate({} Hz) unavailable/declined (non-fatal)",
mode.refresh_hz
);
}
// Skew-corrected latency stats (spec: design/stats-unification.md). Receipt stamps (keyed by the
// pts we queue) live in a shared map: the feeder writes them at receipt, this loop pairs decoded
// output back to them. Behind a `Mutex` since two threads touch it — only ever locked while the
// HUD is visible.
let clock_offset = client.clock_offset_ns;
let in_flight = Arc::new(Mutex::new(VecDeque::<(u64, i128)>::new()));
// Feeder thread: block on the network so this loop doesn't (an AU's arrival becomes an event that
// wakes us immediately, with no input-side poll latency). It also records the `received` HUD stat.
let feeder = {
let client = client.clone();
let stats = stats.clone();
let in_flight = in_flight.clone();
let shutdown = shutdown.clone();
let ev_tx = ev_tx.clone();
std::thread::Builder::new()
.name("pf-decode-feed".into())
.spawn(move || {
feeder_loop(
client,
stats,
in_flight,
clock_offset as i128,
shutdown,
ev_tx,
);
})
.ok()
};
drop(ev_tx); // only the feeder + callbacks keep the channel alive now
// ADPF: same as the sync path — register this thread now, create the session lazily on the first
// presented frame (by when the pump + audio + feeder threads have registered their tids too).
let frame_period_ns = if mode.refresh_hz > 0 {
1_000_000_000i64 / mode.refresh_hz as i64
} else {
0
};
client.register_hot_thread();
let mut hint: Option<crate::adpf::HintSession> = None;
let mut hint_tried = false;
let mut free_inputs: VecDeque<usize> = VecDeque::new();
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
let mut ready: Vec<OutputReady> = Vec::new();
let mut applied_ds: Option<DataSpace> = None;
let mut fed: u64 = 0;
let mut rendered: u64 = 0;
let mut discarded: u64 = 0;
let mut last_dropped = client.frames_dropped();
let mut last_kf_req: Option<Instant> = None;
// Productive (dispatch+feed+present) time between displayed frames; reported to ADPF once one is
// presented. The blocking event wait is excluded (idle, not work) — same accounting as the sync loop.
let mut work_accum_ns: i64 = 0;
let mut fatal = false;
while !shutdown.load(Ordering::Relaxed) && !fatal {
// Block for the next event (idle wait — excluded from the work tally). The short timeout
// drives loss-recovery housekeeping when the pipeline is momentarily quiet.
let ev0 = match ev_rx.recv_timeout(Duration::from_millis(5)) {
Ok(ev) => Some(ev),
Err(mpsc::RecvTimeoutError::Timeout) => None,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
};
let work_t0 = Instant::now();
let mut fmt_dirty = false;
let mut au_dropped = false;
if let Some(ev) = ev0 {
au_dropped |= dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut fatal,
);
}
// Coalesce every other event already queued into this one work pass — correct newest-only
// presentation across a decode burst, and batched feeding.
while let Ok(ev) = ev_rx.try_recv() {
au_dropped |= dispatch_event(
ev,
&mut pending_aus,
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut fatal,
);
}
if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
}
feed_ready(&codec, &mut pending_aus, &mut free_inputs, &mut fed);
let had_output = !ready.is_empty();
present_ready(
&codec,
&mut ready,
&stats,
&in_flight,
clock_offset,
&mut rendered,
&mut discarded,
);
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
if had_output {
if !hint_tried {
hint_tried = true;
let tids = client.hot_thread_ids();
boost_hot_threads(&tids);
hint = crate::adpf::HintSession::create(frame_period_ns, &tids);
log::info!(
"decode: ADPF hint session {} — {} hot thread(s), target {frame_period_ns} ns",
if hint.is_some() {
"active"
} else {
"unavailable"
},
tids.len(),
);
}
if let Some(h) = &hint {
h.report_actual(work_accum_ns);
}
work_accum_ns = 0;
if rendered > 0 && rendered % 300 == 0 {
log::info!("decode: fed={fed} rendered={rendered} discarded={discarded}");
}
}
// Loss recovery: request an IDR when the reassembler's unrecoverable-drop count climbs (or we
// dropped a parked AU on overflow), throttled so a multi-frame recovery gap doesn't flood the
// control stream.
let dropped = client.frames_dropped();
if dropped > last_dropped || au_dropped {
last_dropped = dropped;
let now = Instant::now();
if last_kf_req.is_none_or(|t| now.duration_since(t) >= Duration::from_millis(100)) {
last_kf_req = Some(now);
let _ = client.request_keyframe();
}
}
}
let _ = codec.stop();
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
if let Some(j) = feeder {
let _ = j.join();
}
log::info!("decode: stopped (async, fed={fed} rendered={rendered} discarded={discarded})");
}
/// The `pf-decode-feed` thread: block on the connector for the next access unit so the async loop
/// never has to. Records the `received` HUD stat (receipt point) — including the Phase-2 host/network
/// split from any matching 0xCF host timings — then hands the AU to the loop via the event channel.
/// Exits when `shutdown` is set, the session closes, or the loop's receiver is gone.
fn feeder_loop(
client: Arc<NativeClient>,
stats: Arc<crate::stats::VideoStats>,
in_flight: Arc<Mutex<VecDeque<(u64, i128)>>>,
clock_offset: i128,
shutdown: Arc<AtomicBool>,
ev_tx: mpsc::Sender<DecodeEvent>,
) {
// Received AUs awaiting their 0xCF host timing (Phase-2 split), as (pts_ns, capture→received µs).
let mut pending_split: VecDeque<(u64, u64)> = VecDeque::new();
while !shutdown.load(Ordering::Relaxed) {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
if stats.enabled() {
let received_ns = now_realtime_ns();
let lat_ns = received_ns + clock_offset - frame.pts_ns as i128;
let lat_us =
(lat_ns > 0 && lat_ns < 10_000_000_000).then_some((lat_ns / 1000) as u64);
stats.note_received(frame.data.len(), lat_us, clock_offset != 0);
{
let mut g = in_flight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.push_back((frame.pts_ns / 1000, received_ns));
if g.len() > IN_FLIGHT_CAP {
g.pop_front(); // stale — codec never echoed it back
}
}
if let Some(hostnet_us) = lat_us {
pending_split.push_back((frame.pts_ns, hostnet_us));
if pending_split.len() > PENDING_SPLIT_CAP {
pending_split.pop_front();
}
}
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns) {
let (_, hostnet_us) = pending_split.remove(i).unwrap();
stats.note_host_split(
t.host_us as u64,
hostnet_us.saturating_sub(t.host_us as u64),
);
}
}
}
if ev_tx.send(DecodeEvent::Au(frame)).is_err() {
break; // the decode loop is gone
}
}
Err(PunktfunkError::NoFrame) => {} // timeout — re-check shutdown and poll again
Err(_) => break, // session closed
}
}
}
/// Route one [`DecodeEvent`] into the loop's working sets. Returns `true` only when a parked AU was
/// dropped on overflow (the caller then requests a keyframe).
fn dispatch_event(
ev: DecodeEvent,
pending_aus: &mut VecDeque<Frame>,
free_inputs: &mut VecDeque<usize>,
ready: &mut Vec<OutputReady>,
fmt_dirty: &mut bool,
fatal: &mut bool,
) -> bool {
match ev {
DecodeEvent::Au(f) => {
pending_aus.push_back(f);
if pending_aus.len() > FRAME_PARK_CAP {
pending_aus.pop_front(); // sustained overflow — drop oldest, signal a keyframe request
return true;
}
}
DecodeEvent::InputAvailable(i) => free_inputs.push_back(i),
DecodeEvent::OutputAvailable { index, pts_us } => ready.push(OutputReady { index, pts_us }),
DecodeEvent::FormatChanged => *fmt_dirty = true,
DecodeEvent::Error { fatal: f } => {
if f {
*fatal = true;
}
}
}
false
}
/// Queue as many parked AUs as there are free input buffer slots (async mode: the indices come from
/// `InputAvailable` callbacks, not a dequeue). Each AU is copied into its codec input buffer and
/// submitted; a too-large AU is truncated (logged) rather than dropped.
fn feed_ready(
codec: &MediaCodec,
pending_aus: &mut VecDeque<Frame>,
free_inputs: &mut VecDeque<usize>,
fed: &mut u64,
) {
while !pending_aus.is_empty() && !free_inputs.is_empty() {
let idx = free_inputs.pop_front().unwrap();
let frame = pending_aus.pop_front().unwrap();
let pts_us = frame.pts_ns / 1000;
let Some(dst) = codec.input_buffer(idx) else {
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
continue;
};
let au = &frame.data;
let n = au.len().min(dst.len());
if n < au.len() {
log::warn!(
"decode: AU {} > input buffer {}, truncated",
au.len(),
dst.len()
);
}
// SAFETY: `au` (wire AU) and `dst` (codec input buffer) are distinct allocations, both valid
// for `n` bytes; `MaybeUninit<u8>` is layout-identical to `u8`, so this initializes dst[..n].
unsafe {
std::ptr::copy_nonoverlapping(au.as_ptr(), dst.as_mut_ptr().cast::<u8>(), n);
}
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, 0) {
log::warn!("decode: queue_input_buffer_by_index: {e}");
} else {
*fed += 1;
}
}
}
/// Present only the NEWEST ready output (render = true) and release the rest without rendering — a
/// burst of stale frames on glass is worse than skipping to the freshest (the sync loop's newest-ready
/// policy, callback-driven). Every dequeued buffer, rendered or not, is the HUD's `decoded`
/// measurement point (it finished decoding either way); samples are recorded in pts order so the
/// receipt-map eviction stays monotonic. `ready` is drained.
fn present_ready(
codec: &MediaCodec,
ready: &mut Vec<OutputReady>,
stats: &crate::stats::VideoStats,
in_flight: &Mutex<VecDeque<(u64, i128)>>,
clock_offset: i64,
rendered: &mut u64,
discarded: &mut u64,
) {
if ready.is_empty() {
return;
}
if stats.enabled() {
let mut g = in_flight
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for o in ready.iter() {
note_decoded_pts(stats, &mut g, clock_offset, o.pts_us);
}
}
let last = ready.len() - 1;
for (i, o) in ready.drain(..).enumerate() {
let render = i == last;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => *rendered += 1,
Ok(()) => *discarded += 1,
Err(e) => {
log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}",
o.index
)
}
}
}
}
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
/// streams leave the default alone). The AMediaCodec analogue of the sync loop's `OutputFormatChanged`
/// handling; safe to call repeatedly (`applied_ds` dedups).
fn apply_hdr_dataspace(
codec: &MediaCodec,
window: &NativeWindow,
applied_ds: &mut Option<DataSpace>,
) {
if let Some(ds) = hdr_dataspace(codec) {
if *applied_ds != Some(ds) {
match window.set_buffers_data_space(ds) {
Ok(()) => {
*applied_ds = Some(ds);
log::info!("decode: HDR stream → Surface dataspace {ds}");
}
Err(e) => {
log::warn!("decode: set_buffers_data_space({ds}) failed (non-fatal): {e}")
}
}
}
}
}
/// Raise the pipeline's OTHER hot threads — the core's data-plane pump (UDP receive + FEC
/// reassembly) and the audio decode thread — toward the display band, matching this decode thread's
/// own boost. `setpriority(PRIO_PROCESS, tid)` targets any task in the process, so we do it from
/// here once their tids are known (the same set ADPF hints), without a per-platform priority hook
/// in the shared core. Slightly below the decode thread's -10 so the display path still wins.
/// Best-effort; skips this thread (already boosted) and is non-fatal if the platform refuses.
fn boost_hot_threads(tids: &[i32]) {
// SAFETY: `gettid` is an always-safe syscall on the calling thread.
let self_tid = unsafe { libc::gettid() };
for &tid in tids {
if tid == self_tid {
continue;
}
// SAFETY: `setpriority` with PRIO_PROCESS + a live tid in our own process is an always-safe
// syscall; a refusal is reported via the return value, not UB.
unsafe {
if libc::setpriority(libc::PRIO_PROCESS, tid as libc::id_t, -8) != 0 {
log::debug!("decode: setpriority(-8) on hot tid {tid} failed (non-fatal)");
}
}
}
}
/// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work /// Best-effort: raise the decode thread toward Android's URGENT_DISPLAY band so background work
/// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform /// can't preempt it under load (which shows up as late/dropped frames). Non-fatal if the platform
/// refuses (foreground apps may set their own threads; the exact floor is policy-dependent). /// refuses (foreground apps may set their own threads; the exact floor is policy-dependent).
@@ -340,6 +989,57 @@ fn boost_thread_priority() {
} }
} }
/// Set the surface's frame-rate hint to the stream's refresh so SurfaceFlinger picks a matching
/// display mode and aligns vsync (no 60-in-120 judder). Both NDK entry points sit above our API-28
/// floor, so both are dlsym-resolved at runtime (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 [`crate::adpf`]):
/// - On a **TV** (`is_tv`): `ANativeWindow_setFrameRateWithChangeStrategy` (**API 31**) with
/// `changeFrameRateStrategy = ALWAYS`, which actively drives the HDMI output into the matching
/// mode (e.g. 60↔120) instead of leaving the panel at its default and judder-matching. The
/// forced switch may blank the panel briefly — acceptable once at stream start, not wanted on a
/// phone. Falls through to the 2-arg hint on API 30.
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) with `compatibility = DEFAULT` — the
/// softer, seamless-preferred hint for phones/tablets and the universal fallback.
///
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
/// decline.
fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv: bool) -> bool {
// int32_t ANativeWindow_setFrameRate(ANativeWindow*, float frameRate, int8_t compatibility)
type SetFrameRateFn = unsafe extern "C" fn(*mut c_void, f32, i8) -> i32;
// int32_t ANativeWindow_setFrameRateWithChangeStrategy(
// ANativeWindow*, float frameRate, int8_t compatibility, int8_t changeFrameRateStrategy)
type SetFrameRateStrategyFn = unsafe extern "C" fn(*mut c_void, f32, i8, i8) -> i32;
// SAFETY: `dlopen` of the always-mapped `libandroid.so` (only bumps its refcount; never closed —
// process-lifetime handle). Each `dlsym` returns null when the symbol is absent (device below the
// symbol's API level), 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;
}
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 = ALWAYS,
// compatibility 0 = DEFAULT). Absent on API 30 ⇒ fall through to the 2-arg hint below.
if is_tv {
let sym = libc::dlsym(
lib,
c"ANativeWindow_setFrameRateWithChangeStrategy".as_ptr(),
);
if !sym.is_null() {
let set = std::mem::transmute::<*mut c_void, SetFrameRateStrategyFn>(sym);
return set(window.ptr().as_ptr().cast(), frame_rate, 0, 1) == 0;
}
}
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 /// 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 /// `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 /// retries; a hard dequeue/queue error counts as consumed (retrying can't salvage the AU, and
@@ -470,7 +1170,22 @@ fn note_decoded(
clock_offset: i64, clock_offset: i64,
buf: &OutputBuffer<'_>, buf: &OutputBuffer<'_>,
) { ) {
let pts_us = buf.info().presentation_time_us().max(0) as u64; note_decoded_pts(
stats,
in_flight,
clock_offset,
buf.info().presentation_time_us().max(0) as u64,
);
}
/// The [`note_decoded`] body keyed by the echoed `presentationTimeUs` directly — the async loop has
/// the pts (from the output callback's `BufferInfo`) but no borrowed `OutputBuffer`, so it calls this.
fn note_decoded_pts(
stats: &crate::stats::VideoStats,
in_flight: &mut VecDeque<(u64, i128)>,
clock_offset: i64,
pts_us: u64,
) {
let decoded_ns = now_realtime_ns(); let decoded_ns = now_realtime_ns();
// Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go. // Pair the echoed pts back to its receipt stamp, evicting stale (older) entries as we go.
let mut received_ns = None; let mut received_ns = None;
+76 -5
View File
@@ -2,20 +2,31 @@
//! ~1 Hz decode-stats drain for the HUD. //! ~1 Hz decode-stats drain for the HUD.
use jni::objects::JObject; use jni::objects::JObject;
use jni::sys::{jboolean, jdoubleArray, jlong, jsize}; // Used only by the android-gated `nativeStartVideo`; on the host build that fn is cfg'd out.
#[cfg(target_os = "android")]
use jni::objects::JString;
use jni::sys::{jboolean, jdoubleArray, jlong, jsize, jstring};
use jni::JNIEnv; use jni::JNIEnv;
use super::{jni_guard, SessionHandle}; use super::{jni_guard, SessionHandle};
/// `NativeBridge.nativeStartVideo(handle, surface)` — wrap the SurfaceView's `Surface` as an /// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature)`
/// `ANativeWindow` and start the HEVC decode thread rendering onto it. No-op if already started. /// — wrap the SurfaceView's `Surface` as an `ANativeWindow` and start the decode thread rendering
/// onto it. `decoderName` is the codec Kotlin ranked from `MediaCodecList` (`""` = let the platform
/// resolve the default for the MIME); `lowLatencyMode` is the user's master toggle;
/// `lowLatencyFeature` is whether that decoder advertised `FEATURE_LowLatency` (HUD label only).
/// No-op if already started.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
#[no_mangle] #[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo( pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
env: JNIEnv, mut env: JNIEnv,
_this: JObject, _this: JObject,
handle: jlong, handle: jlong,
surface: JObject, surface: JObject,
decoder_name: JString,
low_latency_mode: jboolean,
ll_feature: jboolean,
is_tv: jboolean,
) { ) {
use super::VideoThread; use super::VideoThread;
use std::sync::atomic::AtomicBool; use std::sync::atomic::AtomicBool;
@@ -24,6 +35,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
if handle == 0 { if handle == 0 {
return; return;
} }
// The decoder name Kotlin picked (empty string / read failure ⇒ None ⇒ default resolver).
let decoder = env
.get_string(&decoder_name)
.ok()
.map(String::from)
.filter(|s| !s.is_empty());
// SAFETY: live handle per the nativeConnect/nativeClose contract. // SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) }; let h = unsafe { &*(handle as *const SessionHandle) };
let mut guard = h.video.lock().unwrap(); let mut guard = h.video.lock().unwrap();
@@ -48,13 +65,67 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
let client = h.client.clone(); let client = h.client.clone();
let sd = shutdown.clone(); let sd = shutdown.clone();
let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate) let st = h.stats.clone(); // session-lifetime stats (gate survives surface recreate)
let opts = crate::decode::DecodeOptions {
decoder_name: decoder,
ll_feature: ll_feature != 0,
low_latency_mode: low_latency_mode != 0,
is_tv: is_tv != 0,
};
let join = std::thread::Builder::new() let join = std::thread::Builder::new()
.name("pf-decode".into()) .name("pf-decode".into())
.spawn(move || crate::decode::run(client, window, sd, st)) .spawn(move || crate::decode::run(client, window, sd, st, opts))
.ok(); .ok();
*guard = Some(VideoThread { shutdown, join }); *guard = Some(VideoThread { shutdown, join });
} }
/// `NativeBridge.nativeVideoMime(handle): String` — the MediaCodec MIME for the codec the host
/// resolved (`"video/hevc"` / `"video/avc"` / `"video/av01"`), so Kotlin can rank `MediaCodecList`
/// decoders for it before calling [`Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo`].
/// Empty string on a `0` handle. Cheap; safe on the UI thread.
#[cfg(target_os = "android")]
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoMime<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
if handle == 0 {
return std::ptr::null_mut();
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(crate::decode::codec_mime(h.client.codec)) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
})
}
/// `NativeBridge.nativeVideoDecoderLabel(handle): String` — the resolved decoder identity for the
/// HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the decode thread has resolved one.
/// One-shot (the decoder is fixed for the session); poll once after the HUD appears. Not
/// android-gated — pure `jni` + a lock, so it links on the host build too (Kotlin only calls it on
/// device).
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoDecoderLabel<'local>(
env: JNIEnv<'local>,
_this: JObject<'local>,
handle: jlong,
) -> jstring {
jni_guard(std::ptr::null_mut(), || {
if handle == 0 {
return std::ptr::null_mut();
}
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
match env.new_string(h.stats.decoder_label()) {
Ok(s) => s.into_raw(),
Err(_) => std::ptr::null_mut(),
}
})
}
/// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the /// `NativeBridge.nativeStopVideo(handle)` — stop + join the decode thread (without closing the
/// session). No-op on `0`. /// session). No-op on `0`.
#[no_mangle] #[no_mangle]
+43
View File
@@ -22,9 +22,21 @@ pub struct VideoStats {
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone. /// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
/// Off until Kotlin shows the HUD. /// Off until Kotlin shows the HUD.
enabled: AtomicBool, enabled: AtomicBool,
/// The resolved decoder identity for the HUD: the codec's actual `AMediaCodec` name (e.g.
/// `c2.qti.avc.decoder`) and whether it advertised `FEATURE_LowLatency`. Set once when the
/// decode thread creates the codec (`set_decoder`), read one-shot by `nativeVideoDecoderLabel`.
/// Separate from `inner` (never touched per-frame) so naming it costs nothing on the hot path.
decoder: Mutex<Option<DecoderInfo>>,
inner: Mutex<Inner>, inner: Mutex<Inner>,
} }
/// The chosen decoder's identity, surfaced on the stats HUD so before/after latency comparisons
/// name the codec that produced them.
struct DecoderInfo {
name: String,
low_latency: bool,
}
struct Inner { struct Inner {
window_start: Instant, window_start: Instant,
frames: u64, frames: u64,
@@ -79,6 +91,7 @@ impl VideoStats {
pub fn new() -> VideoStats { pub fn new() -> VideoStats {
VideoStats { VideoStats {
enabled: AtomicBool::new(false), enabled: AtomicBool::new(false),
decoder: Mutex::new(None),
inner: Mutex::new(Inner { inner: Mutex::new(Inner {
window_start: Instant::now(), window_start: Instant::now(),
frames: 0, frames: 0,
@@ -121,6 +134,36 @@ impl VideoStats {
} }
} }
/// Record the resolved decoder identity for the HUD — the codec's real `AMediaCodec` name and
/// whether it reported `FEATURE_LowLatency`. Called once from the decode thread right after the
/// codec is created (before `configure`), overwriting any prior value on a surface recreate.
// Set only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn set_decoder(&self, name: &str, low_latency: bool) {
let mut g = self
.decoder
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*g = Some(DecoderInfo {
name: name.to_owned(),
low_latency,
});
}
/// The decoder label for the HUD, e.g. `c2.qti.avc.decoder · low-latency`, or `""` before the
/// decode thread has resolved one. Cheap (a lock + a string build); safe on the UI thread.
pub fn decoder_label(&self) -> String {
let g = self
.decoder
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
match &*g {
Some(d) if d.low_latency => format!("{} · low-latency", d.name),
Some(d) => d.name.clone(),
None => String::new(),
}
}
/// Record one received access unit: its wire size and (if in range) its capture→received /// Record one received access unit: its wire size and (if in range) its capture→received
/// `host+network` stage sample. Receipt is the fps/goodput counting point per the spec. /// `host+network` stage sample. Receipt is the fps/goodput counting point per the spec.
// Driven only by the android-only decode thread; unreferenced on the host build — expected. // Driven only by the android-only decode thread; unreferenced on the host build — expected.
@@ -48,21 +48,21 @@
<key>com.apple.security.device.usb</key> <key>com.apple.security.device.usb</key>
<true/> <true/>
<!-- Controller rumble via CoreHaptics: GCDeviceHaptics.createEngine → CHHapticEngine <!-- NO mach-lookup temporary exception here — and none is needed. Build 0.4.2 (3384) shipped a
(GamepadFeedback's RumbleRenderer), and AVAudioEngine playback, reach the system `com.apple.security.temporary-exception.mach-lookup.global-name` = com.apple.audioanalyticsd
audio-analytics daemon `com.apple.audioanalyticsd` over Mach. The sandbox denies that exception on the THEORY that CoreHaptics controller rumble (CHHapticEngine — the session
global-name lookup unless it's whitelisted here, and the framework's own precondition RumbleRenderer + MenuHaptics) hard-crashes under the App Sandbox without it, because the
turns the denial into a HARD CRASH ("Process is sandboxed but framework reaches the audio-analytics daemon over Mach and the sandbox denies that lookup.
com.apple.security.exception.mach-lookup.global-name doesn't contain App Review REJECTED the exception under guideline 2.4.5(i) (review 2026-07-04). We then
com.apple.audioanalyticsd") the moment a controller's haptics engine starts. This tested the premise directly on macOS: a CHHapticEngine start + full-intensity rumble on a
temporary exception is the documented, App-Store-acceptable way to permit exactly that real Xbox pad, in a genuinely ENFORCED sandbox (NSHomeDirectory redirected into the app
lookup — and ONLY that service (the key takes exact names, no wildcards). App Store: container) with NO exception on the codesigned binary — and it ran WITHOUT crashing, rumble
declare it in App Store Connect → App Sandbox Entitlement Usage Information ("CoreHaptics and all, even with a live AVAudioEngine stream running concurrently. CoreHaptics simply
gamepad rumble contacts the system audio-analytics daemon"). --> tolerates the denied audioanalyticsd lookup (it's telemetry, not a hard precondition). So
<key>com.apple.security.temporary-exception.mach-lookup.global-name</key> controller rumble works fully sandboxed with none of these exceptions. Do NOT re-add one —
<array> it will be rejected again AND it buys nothing. (DualSense rumble separately goes over raw
<string>com.apple.audioanalyticsd</string> HID via device.usb/device.bluetooth — CoreHaptics genuinely doesn't drive Sony motors on
</array> macOS — but that path needs no exception either; see DualSenseHID.) -->
<!-- Keychain Sharing (unchanged from the shared file): a team-scoped access group so the <!-- 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 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 awaitingApproval: ApprovalRequest?
@State private var speedTestTarget: StoredHost? @State private var speedTestTarget: StoredHost?
@State private var libraryTarget: 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) #if !os(macOS)
@State private var showSettings = false @State private var showSettings = false
#endif #endif
@@ -212,12 +215,18 @@ struct ContentView: View {
} }
private var home: some 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) #if os(macOS)
Group { Group {
if gamepadUIActive { if gamepadUIActive {
GamepadHomeView( GamepadHomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0) }, connectDiscovered: connectDiscovered) connect: { connect($0) }, connectDiscovered: connectDiscovered)
} else { } else {
HomeView( HomeView(
@@ -225,7 +234,7 @@ struct ContentView: View {
showAddHost: $showAddHost, pairingTarget: $pairingTarget, showAddHost: $showAddHost, pairingTarget: $pairingTarget,
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
connect: { connect($0) }, connectDiscovered: connectDiscovered, connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle) onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
} }
} }
#elseif os(iOS) #elseif os(iOS)
@@ -233,7 +242,7 @@ struct ContentView: View {
if gamepadUIActive { if gamepadUIActive {
GamepadHomeView( GamepadHomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
libraryTarget: $libraryTarget, libraryTarget: $libraryTarget, waker: waker,
connect: { connect($0) }, connectDiscovered: connectDiscovered) connect: { connect($0) }, connectDiscovered: connectDiscovered)
} else { } else {
HomeView( HomeView(
@@ -242,7 +251,7 @@ struct ContentView: View {
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings, showSettings: $showSettings,
connect: { connect($0) }, connectDiscovered: connectDiscovered, connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle) onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
} }
} }
#else #else
@@ -252,7 +261,7 @@ struct ContentView: View {
speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget, speedTestTarget: $speedTestTarget, libraryTarget: $libraryTarget,
showSettings: $showSettings, showSettings: $showSettings,
connect: { connect($0) }, connectDiscovered: connectDiscovered, connect: { connect($0) }, connectDiscovered: connectDiscovered,
onPaired: handlePaired, onLaunchTitle: launchTitle) onPaired: handlePaired, onLaunchTitle: launchTitle, wake: { wakeOnly($0) })
#endif #endif
} }
@@ -406,9 +415,37 @@ struct ContentView: View {
/// delegated-approval connect (host parks it until the operator approves). /// delegated-approval connect (host parks it until the operator approves).
private func startSession( private func startSession(
_ host: StoredHost, launchID: String? = nil, _ 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) 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( model.connect(
to: host, to: host,
width: UInt32(clamping: width), height: UInt32(clamping: height), width: UInt32(clamping: width), height: UInt32(clamping: height),
@@ -452,12 +489,24 @@ struct ContentView: View {
/// as paired (see the `.streaming` branch of `onChange`). /// as paired (see the `.streaming` branch of `onChange`).
private func requestAccess(_ req: ApprovalRequest) { private func requestAccess(_ req: ApprovalRequest) {
guard !model.isBusy else { return } guard !model.isBusy else { return }
awaitingApproval = req
// Pin the advertised certificate for a discovered host (impostor defence during the long // 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. // wait); a manually-typed host has no advertised fingerprint, so trust-on-first-use.
var host = req.host var host = req.host
host.pinnedSHA256 = req.advertisedFingerprint 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 /// 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 // Add / edit a host: name (optional) + address + port + Wake-on-LAN MAC a card in the grid.
// actual connection runs the trust-on-first-use fingerprint prompt. // 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 import SwiftUI
struct AddHostSheet: View { struct AddHostSheet: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var name = ""
@State private var address = "" /// nil = add a new host; non-nil = edit this one (fields prefilled, identity/pin preserved).
@State private var port = 9777 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) #if os(tvOS)
private enum EditField: String, Identifiable { private enum EditField: String, Identifiable {
case name, address, port case name, address, port, mac
var id: String { rawValue } var id: String { rawValue }
} }
@State private var editing: EditField? @State private var editingField: EditField?
#endif #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 { var body: some View {
#if os(tvOS) #if os(tvOS)
// No inline text editing on tvOS Settings-style value rows; pressing one // No inline text editing on tvOS Settings-style value rows; pressing one
// raises the SYSTEM fullscreen keyboard (TVTextEntry). // raises the SYSTEM fullscreen keyboard (TVTextEntry).
VStack(spacing: 24) { VStack(spacing: 24) {
TVFieldRow( TVFieldRow(label: "Name", value: name, placeholder: "Optional") { editingField = .name }
label: "Name", value: name, placeholder: "Optional" TVFieldRow(label: "Address", value: address, placeholder: "IP or hostname") { editingField = .address }
) { editing = .name } TVFieldRow(label: "Port", value: String(port), placeholder: "") { editingField = .port }
TVFieldRow( TVFieldRow(label: "MAC", value: mac, placeholder: "Wake-on-LAN — auto-filled when known") { editingField = .mac }
label: "Address", value: address, placeholder: "IP or hostname"
) { editing = .address }
TVFieldRow(
label: "Port", value: String(port), placeholder: ""
) { editing = .port }
HStack(spacing: 32) { HStack(spacing: 32) {
Button("Cancel", role: .cancel) { dismiss() } Button("Cancel", role: .cancel) { dismiss() }
Button("Add Host") { add() } Button(actionTitle) { save() }.disabled(!canSave)
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
} }
.padding(.top, 12) .padding(.top, 12)
} }
.frame(maxWidth: 1000) .frame(maxWidth: 1000)
.padding(60) .padding(60)
.navigationTitle("Add Host") .navigationTitle(isEditing ? "Edit Host" : "Add Host")
.fullScreenCover(item: $editing) { field in .fullScreenCover(item: $editingField) { field in
switch field { switch field {
case .name: case .name:
TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) { TVTextEntry(title: "Name (optional, e.g. Living Room)", text: name) {
name = $0 name = $0
editing = nil editingField = nil
} }
case .address: case .address:
TVTextEntry(title: "IP or hostname", text: address) { TVTextEntry(title: "IP or hostname", text: address) {
address = $0.trimmingCharacters(in: .whitespaces) address = $0.trimmingCharacters(in: .whitespaces)
editing = nil editingField = nil
} }
case .port: case .port:
TVTextEntry( TVTextEntry(title: "Port", text: String(port), keyboardType: .numberPad) {
title: "Port", text: String(port), keyboardType: .numberPad if let value = Int($0), (1...65535).contains(value) { port = value }
) { editingField = nil
if let value = Int($0), (1...65535).contains(value) {
port = value
} }
editing = 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("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
TextField("Address", text: $address, prompt: Text("IP or hostname")) TextField("Address", text: $address, prompt: Text("IP or hostname"))
TextField("Port", value: $port, format: .number.grouping(.never)) TextField("Port", value: $port, format: .number.grouping(.never))
#if os(tvOS) TextField("MAC", text: $mac, prompt: Text("Wake-on-LAN — auto-filled when known"))
// tvOS floats the label above a non-empty field INSIDE the pill, .autocorrectionDisabled()
// shoving the value off-center the field is always prefilled #if os(iOS)
// here, so drop the label there. .textInputAutocapitalization(.never)
.labelsHidden()
#endif #endif
} }
#if !os(tvOS) #if !os(tvOS)
.formStyle(.grouped) .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 #endif
#if os(iOS) #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) .scrollDisabled(true)
#endif #endif
#if os(macOS) #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 { HStack {
Button("Cancel", role: .cancel) { dismiss() } Button("Cancel", role: .cancel) { dismiss() }
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
Spacer() Spacer()
Button("Add Host") { add() } Button(actionTitle) { save() }
.glassProminentButtonStyle() .glassProminentButtonStyle()
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty) .disabled(!canSave)
} }
.padding(16) .padding(16)
#else #else
// iOS / iPadOS: NO Cancel the sheet is dismissed by the drag indicator, Button { save() } label: {
// swipe-down, or tap-outside. (AddHostSheet never sets interactiveDismissDisabled, Text(actionTitle).frame(maxWidth: .infinity)
// 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)
} }
.glassProminentButtonStyle() .glassProminentButtonStyle()
.controlSize(.large) .controlSize(.large)
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty) .disabled(!canSave)
.padding(16) .padding(16)
#endif #endif
} }
#if os(iOS) #if os(iOS)
// A short bottom sheet, not a full-screen modal. .height(320) hugs the 3-field grouped // Four fields + the action row a touch taller than the 3-field add sheet used to be.
// Form + the full-width action row, instead of the half-screen .medium it used to rest .presentationDetents([.height(392)])
// 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)])
.presentationDragIndicator(.visible) .presentationDragIndicator(.visible)
#endif #endif
#if os(macOS) #if os(macOS)
.frame(width: 380) .frame(width: 400)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
#endif #endif
#endif #endif
} }
private func add() { private func save() {
onAdd(StoredHost( var host = existing ?? StoredHost(name: "", address: "")
name: name.trimmingCharacters(in: .whitespaces), host.name = name.trimmingCharacters(in: .whitespaces)
address: address.trimmingCharacters(in: .whitespaces), host.address = address.trimmingCharacters(in: .whitespaces)
port: UInt16(clamping: port))) host.port = UInt16(clamping: port)
host.macAddresses = Self.parseMacs(mac)
onSave(host)
dismiss() 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(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, compact ? 4 : 8) .padding(.bottom, compact ? 4 : 8)
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.overlay(alignment: .topTrailing) { closeButton.padding(.trailing, 20) } .overlay(alignment: .topTrailing) { closeButton.padding(.top, 20).padding(.trailing, 20) }
.background { GamepadTrayScrim(edge: .top) } .background { GamepadTrayScrim(edge: .top) }
} }
.safeAreaInset(edge: .bottom, spacing: 0) { .safeAreaInset(edge: .bottom, spacing: 0) {
bottomTray bottomTray
.padding(.horizontal, 22) // Equal distance from the left and bottom edges for the legend pill (see GamepadHomeView).
.padding(.vertical, compact ? 6 : 10) .padding(.horizontal, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 6 : 10)
.background { GamepadTrayScrim(edge: .bottom) } .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. // A port can't exceed 5 digits cap while typing so the row can't grow absurd.
.onChange(of: port) { _, value in .onChange(of: port) { _, value in
if value.count > 5 { port = String(value.prefix(5)) } if value.count > 5 { port = String(value.prefix(5)) }
@@ -165,14 +168,16 @@ struct GamepadAddHostView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 13) .padding(.vertical, 13)
.background { // Liquid Glass rows, matching the settings screen; the focused (or actively edited) row
RoundedRectangle(cornerRadius: 14, style: .continuous) // takes the brand wash, and the edited row keeps its brand caret border.
.fill(.white.opacity(focused || editing == row.id ? 0.1 : 0)) .consoleGlass(
} RoundedRectangle(cornerRadius: 14, style: .continuous),
tint: (focused || editing == row.id) ? Color.brand.opacity(0.30) : nil,
interactive: focused)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous) RoundedRectangle(cornerRadius: 14, style: .continuous)
.strokeBorder( .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) lineWidth: 1)
} }
.scaleEffect(focused ? 1.0 : 0.98) .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`). /// 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 { struct GamepadHintBar: View {
let hints: [GamepadHint] let hints: [GamepadHint]
@@ -57,39 +59,141 @@ struct GamepadHintBar: View {
} }
.font(.geist(14, .semibold, relativeTo: .subheadline)) .font(.geist(14, .semibold, relativeTo: .subheadline))
.foregroundStyle(.white.opacity(0.85)) .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 /// The console backdrop: a living aurora in the brand's violet family, drifting slowly over black
/// drifting on slow Lissajous paths over black, in the direction of Apple Music's animated player /// so it reads as ambience behind the cards, never as content. On iOS 18 / macOS 15+ it's an
/// background but calmer (long 3090 s periods, muted opacities, a legibility scrim on top, so it /// animated `MeshGradient` a continuous silk of colour whose control points wander on slow,
/// reads as ambience behind the cards, never as content). Deliberately pure SwiftUI rather than a /// out-of-phase sinusoids finished with an elliptical vignette (pools light in the centre, sinks
/// .metal shader: these sources are built both by SwiftPM (`swift run`/tests) and by the Xcode /// the corners) and a top/bottom legibility scrim. Older OSes fall back to the original drifting
/// project's synchronized folders, and a compiled metallib is only reliably bundled in one of the /// radial-blob field, unchanged, so nothing regresses.
/// two radial gradients driven by a TimelineView give the same look with none of that risk.
/// ///
/// Applied via `.background { }` NOT as a ZStack sibling so the `.ignoresSafeArea()` here /// Deliberately pure SwiftUI, no `.metal`: these sources build under both SwiftPM (`swift run`/
/// can't inflate the caller's layout past the safe area (see the layout discipline note in /// tests) and the Xcode project's synchronized folders, and a compiled metallib is only reliably
/// GamepadHomeView's header). Honors Reduce Motion by freezing the field at a fixed phase. /// 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 { struct GamepadScreenBackground: View {
@Environment(\.accessibilityReduceMotion) private var reduceMotion @Environment(\.accessibilityReduceMotion) private var reduceMotion
/// One drifting color blob: a base position + drift ellipse (unit coordinates), angular var body: some View {
/// speeds (rad/s periods of 3090 s), and a radius that slowly breathes. 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 { private struct Blob {
let color: Color let color: Color
let center: CGPoint let center: CGPoint
let drift: CGSize let drift: CGSize
let speed: (x: Double, y: Double) let speed: (x: Double, y: Double)
let phase: (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 radius: CGFloat
let breathe: (amount: CGFloat, speed: Double) let breathe: (amount: CGFloat, speed: Double)
let opacity: 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] = [ private static let blobs: [Blob] = [
Blob(color: Color(red: 0.53, green: 0.47, blue: 0.96), // brand violet 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), 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 { 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 GeometryReader { geo in
let side = max(geo.size.width, geo.size.height) let side = max(geo.size.width, geo.size.height)
ZStack {
Color.black
ZStack { ZStack {
ForEach(Self.blobs.indices, id: \.self) { i in ForEach(Self.blobs.indices, id: \.self) { i in
blobView(Self.blobs[i], at: t, in: geo.size, side: side) 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() .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)
}
} }
} }
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 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 y = blob.center.y + blob.drift.height * CGFloat(cos(t * blob.speed.y + blob.phase.y))
let r = side * blob.radius 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 /// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray):
/// tray): scrollable rows pass beneath those insets, so without this the tray text and the row /// scrollable rows pass beneath those insets, so without this the tray text and the row underneath
/// underneath render interleaved. Fades toward the content so it reads as depth, not a bar. /// 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 { struct GamepadTrayScrim: View {
let edge: VerticalEdge let edge: VerticalEdge
var body: some View { var body: some View {
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( LinearGradient(
stops: [ stops: [
.init(color: .black.opacity(0.92), location: 0), .init(color: .black, location: 0),
.init(color: .black.opacity(0.85), location: 0.55), .init(color: .black.opacity(0.9), location: 0.5),
.init(color: .black.opacity(0), location: 1), .init(color: .clear, location: 1),
], ],
startPoint: edge == .top ? .top : .bottom, startPoint: fromEdge, endPoint: toContent)
endPoint: edge == .top ? .bottom : .top) }
// Grow past the tray so the fade-to-clear happens OUTSIDE its bounds the tray's own // 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) .padding(edge == .top ? .bottom : .top, -32)
.ignoresSafeArea() .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 /// "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 /// chip in the launcher's top bar. Callers observe GamepadManager already, so this re-renders
/// when the pad or its battery state changes. /// when the pad or its battery state changes.
@@ -44,8 +44,8 @@ private struct HomeTile: Identifiable {
var hasLibrary = false var hasLibrary = false
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile). /// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
var icon: String? var icon: String?
/// Whether the detail panel shows the online/paired pill (hosts yes, actions no). /// Offline saved host we hold a MAC for (and WoL is available) activating it wakes first.
var showsStatus = true var canWake = false
let activate: () -> Void let activate: () -> Void
} }
@@ -54,6 +54,9 @@ struct GamepadHomeView: View {
@ObservedObject var model: SessionModel @ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery @ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost? @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 connect: (StoredHost) -> Void
let connectDiscovered: (DiscoveredHost) -> Void let connectDiscovered: (DiscoveredHost) -> Void
@@ -84,8 +87,11 @@ struct GamepadHomeView: View {
} }
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
GamepadHintBar(hints: hints) GamepadHintBar(hints: hints)
.padding(.leading, 22) // Equal distance from the left and bottom edges the pill's corner inset was the
.padding(.vertical, compact ? 6 : 10) // 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() } .background { GamepadScreenBackground() }
.onAppear { discovery.start() } .onAppear { discovery.start() }
@@ -115,13 +121,13 @@ struct GamepadHomeView: View {
@ViewBuilder private func hero(for size: CGSize) -> some View { @ViewBuilder private func hero(for size: CGSize) -> some View {
let cardWidth = min(340, size.width * 0.84) let cardWidth = min(340, size.width * 0.84)
// 96 the carousel's own vertical breathing (+40) plus the detail line (~54); clamp so // 48 the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
// the strip + detail always fit the region the safe-area insets leave. // always fits the region the pinned title / hints safe-area insets leave. (The old detail
let cardHeight = min(compact ? 170 : 210, max(118, size.height - 96)) // 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) { VStack(spacing: compact ? 8 : 10) {
Spacer(minLength: 0) Spacer(minLength: 0)
carousel(cardWidth: cardWidth, cardHeight: cardHeight) carousel(cardWidth: cardWidth, cardHeight: cardHeight)
detailPanel
Spacer(minLength: 0) Spacer(minLength: 0)
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
@@ -155,9 +161,9 @@ struct GamepadHomeView: View {
onActivate: { $0.activate() }, onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() }, onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true }, onTertiary: { showSettings = true },
// Stop consuming the controller while another screen is presented on top otherwise // Stop consuming the controller while another screen (or the wake overlay) is on top
// the launcher navigates behind it (invisibly on iPhone, visibly on iPad's page sheet). // otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad).
isActive: libraryTarget == nil && !showSettings && !showAddHost isActive: libraryTarget == nil && !showSettings && !showAddHost && waker.waking == nil
) { tile in ) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight)) 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) // MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
private var hints: [GamepadHint] { private var hints: [GamepadHint] {
let selected = tiles.first { $0.id == selection } let selected = tiles.first { $0.id == selection }
var hints = [GamepadHint( var hints = [GamepadHint(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"), 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 { if libraryEnabled, selected?.hasLibrary == true {
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library")) 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, isConnecting: model.phase == .connecting && model.activeHost?.id == host.id,
filled: true, filled: true,
hasLibrary: true, hasLibrary: true,
canWake: PunktfunkConnection.wakeOnLANAvailable
&& !discovery.advertises(host) && !host.wakeMacs.isEmpty,
activate: { connect(host) }) activate: { connect(host) })
} }
let discovered = discovery.unsaved(among: store.hosts).map { d in let discovered = discovery.unsaved(among: store.hosts).map { d in
@@ -267,7 +240,6 @@ struct GamepadHomeView: View {
title: "Add Host", title: "Add Host",
subtitle: "Register a host by address", subtitle: "Register a host by address",
icon: "plus", icon: "plus",
showsStatus: false,
activate: { showAddHost = true }) activate: { showAddHost = true })
return saved + discovered + [add] return saved + discovered + [add]
} }
@@ -291,9 +263,17 @@ private struct GamepadHostTile: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top) { HStack(alignment: .top, spacing: 8) {
monogramBadge monogramBadge
Spacer(minLength: 0) Spacer(minLength: 0)
// 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 { if tile.isOnline {
Circle() Circle()
.fill(Color.green) .fill(Color.green)
@@ -301,6 +281,7 @@ private struct GamepadHostTile: View {
.shadow(color: .green.opacity(0.7), radius: 5) .shadow(color: .green.opacity(0.7), radius: 5)
} }
} }
}
Spacer(minLength: 0) Spacer(minLength: 0)
Text(tile.title) Text(tile.title)
.font(.geist(23, .bold, relativeTo: .title2)) .font(.geist(23, .bold, relativeTo: .title2))
@@ -315,11 +296,11 @@ private struct GamepadHostTile: View {
} }
.padding(20) .padding(20)
.frame(width: size.width, height: size.height, alignment: .leading) .frame(width: size.width, height: size.height, alignment: .leading)
.background { // Liquid Glass console tile a brand wash marks a saved host as primary; discovered /
RoundedRectangle(cornerRadius: 26, style: .continuous) // Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.fill(.ultraThinMaterial) .consoleGlass(
.environment(\.colorScheme, .dark) RoundedRectangle(cornerRadius: 26, style: .continuous),
} tint: tile.filled ? Color.brand.opacity(0.20) : nil)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 26, style: .continuous) RoundedRectangle(cornerRadius: 26, style: .continuous)
.strokeBorder( .strokeBorder(
@@ -328,7 +309,6 @@ private struct GamepadHostTile: View {
startPoint: .top, endPoint: .bottom), startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5])) 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) .shadow(color: .black.opacity(0.45), radius: 20, y: 14)
} }
@@ -26,8 +26,13 @@ struct HomeView: View {
let onPaired: (StoredHost, Data) -> Void let onPaired: (StoredHost, Data) -> Void
/// Picked a title in the (experimental) library start a session that launches it. /// Picked a title in the (experimental) library start a session that launches it.
let onLaunchTitle: (StoredHost, String) -> Void 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. /// Experimental game-library browser (gated) the host-card "Browse Library" action.
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = false @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 { var body: some View {
NavigationStack { NavigationStack {
@@ -126,6 +131,13 @@ struct HomeView: View {
.sheet(isPresented: $showAddHost) { .sheet(isPresented: $showAddHost) {
AddHostSheet { store.add($0) } 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) #if os(iOS)
// SettingsView owns its own NavigationSplitView (sidebar + detail) and Done button, so it // 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 // 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) }, onForget: { store.forgetIdentity(host) },
onRemove: { store.remove(host) }, onRemove: { store.remove(host) },
onBrowseLibrary: onBrowseLibrary, onBrowseLibrary: onBrowseLibrary,
onWake: { onWake: { wake(host) },
let macs = host.wakeMacs onEdit: { editTarget = host })
let ip = host.address
DispatchQueue.global(qos: .userInitiated).async {
PunktfunkConnection.wakeOnLAN(macs: macs, lastKnownIP: ip)
}
})
} }
private var discoveredSection: some View { 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 /// 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"). /// MAC to target (a tap-to-connect already auto-wakes; this is the explicit "just wake it").
var onWake: (() -> Void)? = nil var onWake: (() -> Void)? = nil
/// Open the edit sheet (name / address / port / Wake-on-LAN MAC).
var onEdit: (() -> Void)? = nil
var body: some View { var body: some View {
let m = CardMetrics.current let m = CardMetrics.current
@@ -136,6 +138,9 @@ struct HostCardView: View {
#endif #endif
.disabled(isBusy) .disabled(isBusy)
.contextMenu { .contextMenu {
if let onEdit {
Button("Edit…", systemImage: "pencil", action: onEdit)
}
Button("Pair with PIN…", action: onPair) Button("Pair with PIN…", action: onPair)
Button("Test Network Speed…", action: onSpeedTest) Button("Test Network Speed…", action: onSpeedTest)
if let onBrowseLibrary { 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,7 +18,8 @@ struct ShotScene {
@MainActor @MainActor
enum ShotScenes { enum ShotScenes {
static let all: [ShotScene] = [ static var all: [ShotScene] {
var scenes: [ShotScene] = [
ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) { ShotScene(name: "01-stream", orientation: .landscape, colorScheme: .dark) {
AnyView(ShotStreamHero()) AnyView(ShotStreamHero())
}, },
@@ -35,6 +36,29 @@ enum ShotScenes {
AnyView(ShotSettings()) 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 // MARK: - Mock data
@@ -75,7 +99,7 @@ private struct ShotHome: View {
showAddHost: .constant(false), pairingTarget: .constant(nil), showAddHost: .constant(false), pairingTarget: .constant(nil),
speedTestTarget: .constant(nil), libraryTarget: .constant(nil), speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
connect: { _ in }, connectDiscovered: { _ in }, connect: { _ in }, connectDiscovered: { _ in },
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }) onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
#else #else
HomeView( HomeView(
store: store, model: model, discovery: discovery, store: store, model: model, discovery: discovery,
@@ -83,11 +107,77 @@ private struct ShotHome: View {
speedTestTarget: .constant(nil), libraryTarget: .constant(nil), speedTestTarget: .constant(nil), libraryTarget: .constant(nil),
showSettings: .constant(false), showSettings: .constant(false),
connect: { _ in }, connectDiscovered: { _ in }, connect: { _ in }, connectDiscovered: { _ in },
onPaired: { _, _ in }, onLaunchTitle: { _, _ in }) onPaired: { _, _ in }, onLaunchTitle: { _, _ in }, wake: { _ in })
#endif #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 // MARK: - Settings
private struct ShotSettings: View { 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"), .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(.trailing, 22)
.padding(.vertical, compact ? 6 : 10) .padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 6 : 10)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .bottom) } .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 { .onAppear {
gamepads.refresh() gamepads.refresh()
gamepads.startDiscovery() gamepads.startDiscovery()
@@ -148,13 +152,14 @@ struct GamepadSettingsView: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 13) .padding(.vertical, 13)
.background { // Every row is Liquid Glass; the focused one takes a brand wash and reacts to press.
RoundedRectangle(cornerRadius: 14, style: .continuous) .consoleGlass(
.fill(.white.opacity(focused ? 0.1 : 0)) RoundedRectangle(cornerRadius: 14, style: .continuous),
} tint: focused ? Color.brand.opacity(0.30) : nil,
interactive: focused)
.overlay { .overlay {
RoundedRectangle(cornerRadius: 14, style: .continuous) 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) .scaleEffect(focused ? 1.0 : 0.98)
.animation(.smooth(duration: 0.18), value: focused) .animation(.smooth(duration: 0.18), value: focused)
@@ -98,6 +98,13 @@ final class HostStore: ObservableObject {
hosts.removeAll { $0.id == host.id } 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) { func markConnected(_ hostID: UUID) {
guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return } guard let i = hosts.firstIndex(where: { $0.id == hostID }) else { return }
hosts[i].lastConnected = Date() hosts[i].lastConnected = Date()
@@ -67,3 +67,41 @@ extension View {
modifier(GlassProminentButton()) 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( TextField(
"PIN", text: $pin, "PIN", text: $pin,
prompt: Text("Shown in the host's web console")) 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) #if os(iOS)
.keyboardType(.numberPad) .keyboardType(.numberPad)
#endif #endif
@@ -134,6 +134,11 @@ struct PairSheet: View {
} }
#if !os(tvOS) #if !os(tvOS)
.formStyle(.grouped) .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 #endif
HStack { HStack {
Button("Cancel", role: .cancel) { Button("Cancel", role: .cancel) {
+1 -1
View File
@@ -80,7 +80,7 @@ const QamPanel: FC = () => {
{/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's {/* Pinned games — the "jump straight into Playnite" rows. Pin games from a host's
picker (fullscreen page → host row → games button). */} picker (fullscreen page → host row → games button). */}
{pins.pins.length > 0 && ( {pins.pins.length > 0 && (
<PanelSection title="Games"> <PanelSection title="Pinned Games">
{pins.pins.map((pin) => { {pins.pins.map((pin) => {
const { online } = resolvePinHost(pin, hosts); const { online } = resolvePinHost(pin, hosts);
return ( return (
+13 -14
View File
@@ -3,13 +3,14 @@
// can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in // can take seconds, hence the explicit spinner copy) and pins titles as one-tap rows in
// the QAM's Games section; its header also launches the GTK client's on-screen gamepad // the QAM's Games section; its header also launches the GTK client's on-screen gamepad
// library (`--browse`). // library (`--browse`).
import { DialogButton, Field, Focusable, ModalRoot, Spinner, showModal } from "@decky/ui"; import { DialogButton, Field, ModalRoot, Spinner, showModal } from "@decky/ui";
import { CSSProperties, FC, useEffect, useState } from "react"; import { FC, useEffect, useState } from "react";
import { FaThLarge, FaTv } from "react-icons/fa"; import { FaThLarge, FaTv } from "react-icons/fa";
import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend"; import { GameEntry, Host, library, LibraryResult, PinnedGame } from "./backend";
import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks"; import { PinsApi, resolvePinHost, startBrowse, startStream } from "./hooks";
import { isSafeLaunchId } from "./steam"; import { isSafeLaunchId } from "./steam";
import { PairModal } from "./pair"; import { PairModal } from "./pair";
import { RowActions, actionButton } from "./ui";
/** Human store tag (mirrors the GTK client's `store_label`). */ /** Human store tag (mirrors the GTK client's `store_label`). */
export function storeLabel(store: string): string { export function storeLabel(store: string): string {
@@ -58,12 +59,6 @@ export function streamPin(pin: PinnedGame, live: Host[], pins: PinsApi): void {
void startStream(host, { launchId: pin.game_id }, pin.title); void startStream(host, { launchId: pin.game_id }, pin.title);
} }
const pickButton: CSSProperties = {
width: "fit-content",
minWidth: "5em",
flexShrink: 0,
};
// Copy per backend error code (LibraryResult.error); `detail` covers the generic case. // Copy per backend error code (LibraryResult.error); `detail` covers the generic case.
function errorCopy(res: LibraryResult): string { function errorCopy(res: LibraryResult): string {
switch (res.error) { switch (res.error) {
@@ -143,8 +138,9 @@ export const GamePickerModal: FC<{
description="Browse this host's games with the controller, full screen" description="Browse this host's games with the controller, full screen"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<RowActions>
<DialogButton <DialogButton
style={pickButton} style={actionButton}
onClick={() => { onClick={() => {
closeModal?.(); closeModal?.();
void startBrowse(host); void startBrowse(host);
@@ -153,6 +149,7 @@ export const GamePickerModal: FC<{
<FaTv style={{ marginRight: "0.4em" }} /> <FaTv style={{ marginRight: "0.4em" }} />
Open Open
</DialogButton> </DialogButton>
</RowActions>
</Field> </Field>
{clientUpdatePending && ( {clientUpdatePending && (
@@ -177,10 +174,10 @@ export const GamePickerModal: FC<{
{result !== null && !result.ok && ( {result !== null && !result.ok && (
<Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max"> <Field label="Couldn't fetch the library" description={errorCopy(result)} childrenContainerWidth="max">
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}> <RowActions>
{result.error === "not-paired" && ( {result.error === "not-paired" && (
<DialogButton <DialogButton
style={pickButton} style={actionButton}
onClick={() => onClick={() =>
showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />) showModal(<PairModal host={host} onPaired={() => setAttempt((n) => n + 1)} />)
} }
@@ -188,10 +185,10 @@ export const GamePickerModal: FC<{
Pair Pair
</DialogButton> </DialogButton>
)} )}
<DialogButton style={pickButton} onClick={() => setAttempt((n) => n + 1)}> <DialogButton style={actionButton} onClick={() => setAttempt((n) => n + 1)}>
Retry Retry
</DialogButton> </DialogButton>
</Focusable> </RowActions>
</Field> </Field>
)} )}
@@ -217,10 +214,12 @@ export const GamePickerModal: FC<{
} }
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton style={pickButton} disabled={!safe} onClick={() => togglePin(g)}> <RowActions>
<DialogButton style={actionButton} disabled={!safe} onClick={() => togglePin(g)}>
<FaThLarge style={{ marginRight: "0.4em" }} /> <FaThLarge style={{ marginRight: "0.4em" }} />
{pinned ? "Unpin" : "Pin"} {pinned ? "Unpin" : "Pin"}
</DialogButton> </DialogButton>
</RowActions>
</Field> </Field>
); );
})} })}
+34 -42
View File
@@ -10,6 +10,7 @@ import {
showModal, showModal,
staticClasses, staticClasses,
} from "@decky/ui"; } from "@decky/ui";
import { RowActions, actionButton, iconButton } from "./ui";
import { toaster } from "@decky/api"; import { toaster } from "@decky/api";
import { CSSProperties, FC, useState } from "react"; import { CSSProperties, FC, useState } from "react";
import { import {
@@ -58,27 +59,6 @@ const tabScroll: CSSProperties = {
boxSizing: "border-box", boxSizing: "border-box",
}; };
// DialogButton stretches to 100% width in the gamepad UI — on a fullscreen row that means a
// screen-wide button. Size action buttons to their content instead (right-aligned by the
// Field's children container).
const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "6em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow) — needs an explicit height too, or
// the zero padding collapses it to the icon's line height.
const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
// ---------------------------------------------------------------------------------------- // ----------------------------------------------------------------------------------------
// Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check // Host details — everything the mDNS advert told us, incl. the fingerprint to cross-check
// against the host's own log / web console before trusting it. // against the host's own log / web console before trusting it.
@@ -144,7 +124,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
}`} }`}
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}> <RowActions>
<DialogButton <DialogButton
style={iconButton} style={iconButton}
onClick={() => showModal(<HostDetailsModal host={host} />)} onClick={() => showModal(<HostDetailsModal host={host} />)}
@@ -153,13 +133,13 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
</DialogButton> </DialogButton>
{/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen {/* Labeled, not icon-only: this is the entry to the game picker AND the on-screen
library browser, and controller nav has no hover tooltip to explain a bare icon. */} library browser, and controller nav has no hover tooltip to explain a bare icon. */}
<DialogButton style={{ ...actionButton, minWidth: "6em" }} onClick={onGames}> <DialogButton style={actionButton} onClick={onGames}>
<FaThLarge style={{ marginRight: "0.4em" }} /> <FaThLarge style={{ marginRight: "0.4em" }} />
Games Games
</DialogButton> </DialogButton>
{needsPair && ( {needsPair && (
<DialogButton <DialogButton
style={{ ...actionButton, minWidth: "5em" }} style={actionButton}
onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)} onClick={() => showModal(<PairModal host={host} onPaired={onPaired} />)}
> >
Pair Pair
@@ -178,7 +158,7 @@ const HostRow: FC<{ host: Host; onPaired: () => void; onGames: () => void }> = (
<FaPlay style={{ marginRight: "0.4em" }} /> <FaPlay style={{ marginRight: "0.4em" }} />
Stream Stream
</DialogButton> </DialogButton>
</Focusable> </RowActions>
</Field> </Field>
); );
}; };
@@ -201,7 +181,8 @@ const HostsTab: FC<{
childrenContainerWidth="max" childrenContainerWidth="max"
bottomSeparator={hosts.length ? "standard" : "none"} bottomSeparator={hosts.length ? "standard" : "none"}
> >
<DialogButton style={{ ...actionButton, minWidth: "8em" }} disabled={scanning} onClick={refresh}> <RowActions>
<DialogButton style={actionButton} disabled={scanning} onClick={refresh}>
{scanning ? ( {scanning ? (
<Spinner style={{ height: "1em", marginRight: "0.5em" }} /> <Spinner style={{ height: "1em", marginRight: "0.5em" }} />
) : ( ) : (
@@ -209,6 +190,7 @@ const HostsTab: FC<{
)} )}
{scanning ? "Scanning…" : "Refresh"} {scanning ? "Scanning…" : "Refresh"}
</DialogButton> </DialogButton>
</RowActions>
</Field> </Field>
{hosts.length === 0 && !scanning && ( {hosts.length === 0 && !scanning && (
@@ -251,18 +233,18 @@ const HostsTab: FC<{
}${pin.paired ? "" : " · pairing required"}`} }${pin.paired ? "" : " · pairing required"}`}
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<Focusable style={{ display: "flex", gap: "0.5em", justifyContent: "flex-end" }}> <RowActions>
<DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}> <DialogButton style={actionButton} onClick={() => streamPin(pin, hosts, pins)}>
<FaPlay style={{ marginRight: "0.4em" }} /> <FaPlay style={{ marginRight: "0.4em" }} />
Play Play
</DialogButton> </DialogButton>
<DialogButton <DialogButton
style={{ ...actionButton, minWidth: "5em" }} style={actionButton}
onClick={() => pins.removePin(pin.host_fp, pin.game_id)} onClick={() => pins.removePin(pin.host_fp, pin.game_id)}
> >
Remove Remove
</DialogButton> </DialogButton>
</Focusable> </RowActions>
</Field> </Field>
); );
})} })}
@@ -306,13 +288,15 @@ const AboutTab: FC<{
} }
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<RowActions>
<DialogButton <DialogButton
style={{ ...actionButton, minWidth: "11em" }} style={actionButton}
disabled={checking} disabled={checking}
onClick={() => void checkForUpdatesNow(check)} onClick={() => void checkForUpdatesNow(check)}
> >
{checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"} {checking ? <Spinner style={{ height: "1em" }} /> : "Check for updates"}
</DialogButton> </DialogButton>
</RowActions>
</Field> </Field>
{hasUpdate(update) && ( {hasUpdate(update) && (
<Field <Field
@@ -326,13 +310,12 @@ const AboutTab: FC<{
description="Installing can take a couple of minutes; Decky reloads the plugin when done" description="Installing can take a couple of minutes; Decky reloads the plugin when done"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton <RowActions>
style={{ ...actionButton, minWidth: "9em" }} <DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
onClick={() => applyUpdate(update!, check)}
>
<FaDownload style={{ marginRight: "0.4em" }} /> <FaDownload style={{ marginRight: "0.4em" }} />
Update Update
</DialogButton> </DialogButton>
</RowActions>
</Field> </Field>
)} )}
<Field <Field
@@ -340,13 +323,15 @@ const AboutTab: FC<{
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io" description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<RowActions>
<DialogButton <DialogButton
style={{ ...actionButton, minWidth: "8em" }} style={actionButton}
onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)} onClick={() => Navigation.NavigateToExternalWeb(DOCS_URL)}
> >
<FaExternalLinkAlt style={{ marginRight: "0.4em" }} /> <FaExternalLinkAlt style={{ marginRight: "0.4em" }} />
Open Open
</DialogButton> </DialogButton>
</RowActions>
</Field> </Field>
<Field <Field
focusable={false} focusable={false}
@@ -358,9 +343,11 @@ const AboutTab: FC<{
description="Force-stop the stream client if a session wedges" description="Force-stop the stream client if a session wedges"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<DialogButton style={{ ...actionButton, minWidth: "8em" }} onClick={() => void forceStopStream()}> <RowActions>
<DialogButton style={actionButton} onClick={() => void forceStopStream()}>
Force-stop Force-stop
</DialogButton> </DialogButton>
</RowActions>
</Field> </Field>
</div> </div>
); );
@@ -399,16 +386,21 @@ const PunktfunkPage: FC = () => {
</div> </div>
</Focusable> </Focusable>
{/* overflow:hidden is load-bearing: Valve's Tabs slides the incoming panel in from the {/* Two things fight each other on an L1/R1 tab switch:
right on L1/R1, and with autoFocusContents it scrollIntoViews a control inside that 1. Valve's Tabs slides the incoming panel in from the right with a CSS transform.
still-offscreen panel. Without a clip here the scroll pans #GamepadUI itself — the whole 2. `autoFocusContents` then focuses a control inside that still-offscreen panel, which
Steam UI (top bar included) slides left until you click a tab. Valve's own Tabs always fires scrollIntoView. Because the panel is offset by a *transform* (not by scroll
live in a clipped flex box; match that. */} position), scrollIntoView can't satisfy it by scrolling any one ancestor, so it walks
up and pans the whole page — the "screen jumps right, then animates back" glitch.
Dropping autoFocusContents removes the scrollIntoView entirely, so nothing fights the
slide. L1/R1 still cycles tabs (that handler lives on the Tabs focus scope, active while
focus is anywhere inside — including the tab strip); after a switch, focus stays on the
strip and Down enters the content, which is how Steam's own tabbed pages behave.
The overflow:hidden clip stays as defense-in-depth against any stray horizontal pan. */}
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}> <div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
<Tabs <Tabs
activeTab={tab} activeTab={tab}
onShowTab={(id: string) => setTab(id)} onShowTab={(id: string) => setTab(id)}
autoFocusContents
tabs={[ tabs={[
{ {
id: "hosts", id: "hosts",
+29 -1
View File
@@ -2,8 +2,20 @@
// the flatpak client's JSON (main.py set_settings), which the client reads on launch. The // the flatpak client's JSON (main.py set_settings), which the client reads on launch. The
// accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`. // accepted gamepad/compositor names mirror punktfunk-core's `*Pref::from_name`.
import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui"; import { Dropdown, Field, SliderField, Spinner, ToggleField } from "@decky/ui";
import { FC, useEffect, useState } from "react"; import { CSSProperties, FC, useEffect, useState } from "react";
import { getSettings, setSettings, StreamSettings } from "./backend"; import { getSettings, setSettings, StreamSettings } from "./backend";
import { RowActions } from "./ui";
// Decky's Dropdown has no width prop — it fills whatever container it's in, and a
// `childrenContainerWidth="max"` Field is the whole row. Wrapping it in this fit-content shell
// (inside the right-aligned RowActions) shrinks the control to its selected label, with a floor
// so short values like "60 Hz" don't collapse to a nub and a ceiling so nothing runs edge to
// edge. Matches the right-aligned, content-sized buttons everywhere else.
const selectShell: CSSProperties = {
width: "fit-content",
minWidth: "10em",
maxWidth: "24em",
};
const RESOLUTIONS: [number, number, string][] = [ const RESOLUTIONS: [number, number, string][] = [
[0, 0, "Native display"], [0, 0, "Native display"],
@@ -61,6 +73,8 @@ export const SettingsSection: FC = () => {
description="The host creates a virtual output at exactly this size" description="The host creates a virtual output at exactly this size"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<RowActions>
<div style={selectShell}>
<Dropdown <Dropdown
rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))} rgOptions={RESOLUTIONS.map(([, , label], i) => ({ data: i, label }))}
selectedOption={resIdx} selectedOption={resIdx}
@@ -69,13 +83,19 @@ export const SettingsSection: FC = () => {
patch({ width: w, height: h }); patch({ width: w, height: h });
}} }}
/> />
</div>
</RowActions>
</Field> </Field>
<Field label="Refresh rate" childrenContainerWidth="max"> <Field label="Refresh rate" childrenContainerWidth="max">
<RowActions>
<div style={selectShell}>
<Dropdown <Dropdown
rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))} rgOptions={REFRESH.map((r) => ({ data: r, label: r === 0 ? "Native" : `${r} Hz` }))}
selectedOption={s.refresh_hz} selectedOption={s.refresh_hz}
onChange={(o) => patch({ refresh_hz: o.data as number })} onChange={(o) => patch({ refresh_hz: o.data as number })}
/> />
</div>
</RowActions>
</Field> </Field>
<SliderField <SliderField
label="Bitrate" label="Bitrate"
@@ -93,11 +113,15 @@ export const SettingsSection: FC = () => {
description="Which virtual controller the host creates for your inputs" description="Which virtual controller the host creates for your inputs"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<RowActions>
<div style={selectShell}>
<Dropdown <Dropdown
rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))} rgOptions={GAMEPADS.map((g) => ({ data: g, label: GAMEPAD_LABELS[g] ?? g }))}
selectedOption={s.gamepad} selectedOption={s.gamepad}
onChange={(o) => patch({ gamepad: o.data as string })} onChange={(o) => patch({ gamepad: o.data as string })}
/> />
</div>
</RowActions>
</Field> </Field>
{(s.gamepad === "steamdeck" || s.gamepad === "auto") && ( {(s.gamepad === "steamdeck" || s.gamepad === "auto") && (
<Field <Field
@@ -110,11 +134,15 @@ export const SettingsSection: FC = () => {
description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host" description="Which compositor backend the host uses for the virtual display — Automatic suits almost every host"
childrenContainerWidth="max" childrenContainerWidth="max"
> >
<RowActions>
<div style={selectShell}>
<Dropdown <Dropdown
rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))} rgOptions={COMPOSITORS.map((c) => ({ data: c, label: COMPOSITOR_LABELS[c] ?? c }))}
selectedOption={s.compositor} selectedOption={s.compositor}
onChange={(o) => patch({ compositor: o.data as string })} onChange={(o) => patch({ compositor: o.data as string })}
/> />
</div>
</RowActions>
</Field> </Field>
<ToggleField <ToggleField
label="Stream microphone" label="Stream microphone"
+46
View File
@@ -0,0 +1,46 @@
// Shared UI primitives for the fullscreen page + modals. The one rule that keeps every row
// looking consistent: a Field's action(s) always sit right-aligned, with real space between
// them and the label text — never hugging it.
//
// Decky lays a Field out as `[ label .......... children ]`. When the children container is
// grown (`childrenContainerWidth="max"`, which we want so multi-button clusters have room), a
// bare `fit-content` button LEFT-aligns inside that grown container and ends up pressed against
// the label with the space wasted to its right. Wrapping the action(s) in `RowActions` pushes
// them to the right edge and evenly spaces multiples — the same treatment every row now gets.
import { Focusable } from "@decky/ui";
import { CSSProperties, FC, ReactNode } from "react";
export const RowActions: FC<{ children: ReactNode }> = ({ children }) => (
<Focusable
style={{
display: "flex",
gap: "0.5em",
justifyContent: "flex-end",
alignItems: "center",
}}
>
{children}
</Focusable>
);
// A single action button sized to its content (not the gamepad-UI default of 100% width), with
// a floor so short labels ("Pair", "Remove") don't render as tiny nubs and every row's button
// reads at the same weight.
export const actionButton: CSSProperties = {
width: "fit-content",
minWidth: "7em",
flexShrink: 0,
};
// Square icon-only button (details ⓘ, header back arrow). Needs an explicit height or the zero
// padding collapses it to the icon's line height.
export const iconButton: CSSProperties = {
width: "40px",
minWidth: "40px",
height: "40px",
padding: 0,
flexShrink: 0,
display: "flex",
alignItems: "center",
justifyContent: "center",
};
+22 -20
View File
@@ -30,15 +30,17 @@ use std::sync::{Arc, Mutex};
// --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------ // --- EGL_EXT_image_dma_buf_import(+_modifiers) constants (khronos-egl exposes none) ------
const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270; const EGL_LINUX_DMA_BUF_EXT: egl::Enum = 0x3270;
const EGL_LINUX_DRM_FOURCC_EXT: usize = 0x3271; // eglCreateImageKHR takes 32-bit EGLint attribs (the core-1.5 eglCreateImage variant is the
const EGL_DMA_BUF_PLANE0_FD_EXT: usize = 0x3272; // one with pointer-sized EGLAttrib) — using the wrong width feeds the driver garbage pairs.
const EGL_DMA_BUF_PLANE0_OFFSET_EXT: usize = 0x3273; const EGL_LINUX_DRM_FOURCC_EXT: i32 = 0x3271;
const EGL_DMA_BUF_PLANE0_PITCH_EXT: usize = 0x3274; const EGL_DMA_BUF_PLANE0_FD_EXT: i32 = 0x3272;
const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: usize = 0x3443; const EGL_DMA_BUF_PLANE0_OFFSET_EXT: i32 = 0x3273;
const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: usize = 0x3444; const EGL_DMA_BUF_PLANE0_PITCH_EXT: i32 = 0x3274;
const EGL_WIDTH: usize = 0x3057; const EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT: i32 = 0x3443;
const EGL_HEIGHT: usize = 0x3056; const EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT: i32 = 0x3444;
const EGL_NONE: usize = 0x3038; const EGL_WIDTH: i32 = 0x3057;
const EGL_HEIGHT: i32 = 0x3056;
const EGL_NONE: i32 = 0x3038;
const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff; const DRM_FORMAT_MOD_INVALID: u64 = 0x00ff_ffff_ffff_ffff;
/// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when /// `fourcc('N','V','1','2')` — the only decoder output today (8-bit 4:2:0). P010 joins when
@@ -140,7 +142,7 @@ type EglCreateImageKhr = unsafe extern "C" fn(
*mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf) *mut c_void, // EGLContext (EGL_NO_CONTEXT for dmabuf)
egl::Enum, egl::Enum,
*mut c_void, // EGLClientBuffer (null for dmabuf) *mut c_void, // EGLClientBuffer (null for dmabuf)
*const usize, *const i32, // EGLint attrib list (KHR variant — NOT pointer-sized EGLAttrib)
) -> *const c_void; ) -> *const c_void;
type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean; type EglDestroyImageKhr = unsafe extern "C" fn(*mut c_void, *const c_void) -> egl::Boolean;
@@ -464,24 +466,24 @@ impl GlConverter {
) -> Result<*const c_void> { ) -> Result<*const c_void> {
let mut attribs = vec![ let mut attribs = vec![
EGL_WIDTH, EGL_WIDTH,
width as usize, width as i32,
EGL_HEIGHT, EGL_HEIGHT,
height as usize, height as i32,
EGL_LINUX_DRM_FOURCC_EXT, EGL_LINUX_DRM_FOURCC_EXT,
fourcc as usize, fourcc as i32,
EGL_DMA_BUF_PLANE0_FD_EXT, EGL_DMA_BUF_PLANE0_FD_EXT,
plane.fd as usize, plane.fd,
EGL_DMA_BUF_PLANE0_OFFSET_EXT, EGL_DMA_BUF_PLANE0_OFFSET_EXT,
plane.offset as usize, plane.offset as i32,
EGL_DMA_BUF_PLANE0_PITCH_EXT, EGL_DMA_BUF_PLANE0_PITCH_EXT,
plane.stride as usize, plane.stride as i32,
]; ];
if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 { if modifier != DRM_FORMAT_MOD_INVALID && modifier != 0 {
attribs.extend_from_slice(&[ attribs.extend_from_slice(&[
EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT, EGL_DMA_BUF_PLANE0_MODIFIER_LO_EXT,
(modifier & 0xffff_ffff) as usize, (modifier & 0xffff_ffff) as u32 as i32,
EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT, EGL_DMA_BUF_PLANE0_MODIFIER_HI_EXT,
(modifier >> 32) as usize, (modifier >> 32) as u32 as i32,
]); ]);
} }
attribs.push(EGL_NONE); attribs.push(EGL_NONE);
@@ -497,12 +499,12 @@ impl GlConverter {
}; };
if img.is_null() { if img.is_null() {
bail!( bail!(
"eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:#x}", "eglCreateImageKHR rejected plane ({}x{} {:#x} mod {:#018x}): {:?}",
width, width,
height, height,
fourcc, fourcc,
modifier, modifier,
self.egl.get_error().map(|e| e as u32).unwrap_or(0) self.egl.get_error()
); );
} }
Ok(img) Ok(img)
+28 -4
View File
@@ -73,6 +73,14 @@ struct Args {
/// `--rich-input-test` — drive the DualSense touchpad + motion over 0xCC (host needs /// `--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. /// `PUNKTFUNK_GAMEPAD=dualsense`); also logs the 0xCD HID-output feedback that comes back.
rich_input_test: bool, 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]>, pin: Option<[u8; 32]>,
/// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream. /// `--remode WxHxFPS:SECS` — request this mode SECS seconds into the stream.
remode: Option<(Mode, u32)>, remode: Option<(Mode, u32)>,
@@ -211,6 +219,8 @@ fn parse_args() -> Args {
mic_burst: argv.iter().any(|a| a == "--mic-burst"), mic_burst: argv.iter().any(|a| a == "--mic-burst"),
touch_test: argv.iter().any(|a| a == "--touch-test"), touch_test: argv.iter().any(|a| a == "--touch-test"),
rich_input_test: argv.iter().any(|a| a == "--rich-input-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, pin,
remode, remode,
pair: get("--pair").map(String::from), pair: get("--pair").map(String::from),
@@ -412,7 +422,7 @@ async fn session(args: Args) -> Result<()> {
io::write_msg( io::write_msg(
&mut send, &mut send,
&Hello { &Hello {
abi_version: punktfunk_core::ABI_VERSION, abi_version: punktfunk_core::WIRE_VERSION,
mode: args.mode, mode: args.mode,
compositor: args.compositor, compositor: args.compositor,
gamepad: args.gamepad, gamepad: args.gamepad,
@@ -1041,6 +1051,9 @@ async fn session(args: Args) -> Result<()> {
let mut net_us_v: Vec<u64> = Vec::new(); let mut net_us_v: Vec<u64> = Vec::new();
let mut last_rx = std::time::Instant::now(); let mut last_rx = std::time::Instant::now();
let started = 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. // 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_loss_report = std::time::Instant::now();
let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64); let (mut last_recovered, mut last_received, mut last_dropped) = (0u64, 0u64, 0u64);
@@ -1076,8 +1089,8 @@ async fn session(args: Args) -> Result<()> {
{ {
break; 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) || last_rx.elapsed() > std::time::Duration::from_secs(45)
{ {
break; 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 result
} }
+29 -2
View File
@@ -179,6 +179,10 @@ pub struct NativeClient {
/// Speed-test accumulator, shared with the data-plane pump + control task. /// Speed-test accumulator, shared with the data-plane pump + control task.
probe: Arc<Mutex<ProbeState>>, probe: Arc<Mutex<ProbeState>>,
shutdown: Arc<AtomicBool>, 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 /// 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 /// 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 /// 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 (ctrl_tx, ctrl_rx) = tokio::sync::mpsc::unbounded_channel::<CtrlRequest>();
let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>(); let (ready_tx, ready_rx) = std::sync::mpsc::channel::<Result<Negotiated>>();
let shutdown = Arc::new(AtomicBool::new(false)); 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 mode_slot = Arc::new(std::sync::Mutex::new(mode));
let probe = Arc::new(Mutex::new(ProbeState::default())); let probe = Arc::new(Mutex::new(ProbeState::default()));
let frames_dropped = Arc::new(AtomicU64::new(0)); let frames_dropped = Arc::new(AtomicU64::new(0));
@@ -338,6 +343,7 @@ impl NativeClient {
let host = host.to_string(); let host = host.to_string();
let shutdown_w = shutdown.clone(); let shutdown_w = shutdown.clone();
let quit_w = quit.clone();
let mode_slot_w = mode_slot.clone(); let mode_slot_w = mode_slot.clone();
let probe_w = probe.clone(); let probe_w = probe.clone();
let frames_dropped_w = frames_dropped.clone(); let frames_dropped_w = frames_dropped.clone();
@@ -388,6 +394,7 @@ impl NativeClient {
ctrl_tx: ctrl_tx_pump, ctrl_tx: ctrl_tx_pump,
ready_tx, ready_tx,
shutdown: shutdown_w, shutdown: shutdown_w,
quit: quit_w,
mode_slot: mode_slot_w, mode_slot: mode_slot_w,
probe: probe_w, probe: probe_w,
frames_dropped: frames_dropped_w, frames_dropped: frames_dropped_w,
@@ -430,6 +437,7 @@ impl NativeClient {
ctrl_tx, ctrl_tx,
probe, probe,
shutdown, shutdown,
quit,
worker: Some(worker), worker: Some(worker),
frames_dropped, frames_dropped,
hot_tids, hot_tids,
@@ -764,6 +772,15 @@ impl NativeClient {
.send(rich) .send(rich)
.map_err(|_| PunktfunkError::Closed) .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 { impl Drop for NativeClient {
@@ -802,6 +819,8 @@ struct WorkerArgs {
ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>, ctrl_tx: tokio::sync::mpsc::UnboundedSender<CtrlRequest>,
ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>, ready_tx: std::sync::mpsc::Sender<Result<Negotiated>>,
shutdown: Arc<AtomicBool>, 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>>, mode_slot: Arc<std::sync::Mutex<Mode>>,
probe: Arc<Mutex<ProbeState>>, probe: Arc<Mutex<ProbeState>>,
frames_dropped: Arc<AtomicU64>, frames_dropped: Arc<AtomicU64>,
@@ -838,6 +857,7 @@ async fn worker_main(args: WorkerArgs) {
ctrl_tx, ctrl_tx,
ready_tx, ready_tx,
shutdown, shutdown,
quit,
mode_slot, mode_slot,
probe, probe,
frames_dropped, frames_dropped,
@@ -876,7 +896,7 @@ async fn worker_main(args: WorkerArgs) {
io::write_msg( io::write_msg(
&mut send, &mut send,
&Hello { &Hello {
abi_version: crate::ABI_VERSION, abi_version: crate::WIRE_VERSION,
mode, mode,
compositor, compositor,
gamepad, gamepad,
@@ -1210,5 +1230,12 @@ async fn worker_main(args: WorkerArgs) {
}) })
.await; .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");
} }
+8
View File
@@ -54,3 +54,11 @@ pub use stats::Stats;
/// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach /// v3: added `punktfunk_wake_on_lan` (Wake-on-LAN magic packet; the host's wake MAC(s) reach
/// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake). /// clients out-of-band via the mDNS `mac` TXT record, so no connection is required to wake).
pub const ABI_VERSION: u32 = 3; pub const ABI_VERSION: u32 = 3;
/// The punktfunk/1 **wire** version — what `Hello`/`Welcome` carry and hosts equality-check.
/// Deliberately its own constant: [`ABI_VERSION`] tracks the embeddable **C surface**
/// (functions a client links), which can grow without changing a single wire byte — v3's
/// `punktfunk_wake_on_lan` is client-local, and riding the C-ABI bump onto the wire locked
/// every new client out of every deployed host ("ABI mismatch: client 3 host 2", observed
/// live). Bump this ONLY when the handshake/planes actually change incompatibly.
pub const WIRE_VERSION: u32 = 2;
+53 -13
View File
@@ -122,6 +122,21 @@ pub const VIDEO_CAP_444: u8 = 0x04;
/// stage. Purely observability — never changes what the host encodes. /// stage. Purely observability — never changes what the host encodes.
pub const VIDEO_CAP_HOST_TIMING: u8 = 0x08; 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;
/// QUIC application error code the **host** closes the control connection with when a **dedicated game
/// session's game process exits** (the nested gamescope died — the user quit the game), so a launcher
/// client can distinguish "the game ended" from an error and return to its library cleanly rather than
/// surfacing a failure (`design/gamemode-and-dedicated-sessions.md` §5.3). Sibling of
/// [`QUIT_CLOSE_CODE`]; a client that doesn't special-case it still ends the session (every client
/// returns to its launcher on session end), so it is purely refinement. Shared so host + clients agree.
pub const APP_EXITED_CLOSE_CODE: u32 = 0x52;
/// [`Hello::video_codecs`] bit: the client can decode H.264 / AVC. The GPU-less **software** /// [`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 /// encode path (openh264) emits H.264, so a client that wants to stream from a software host MUST
/// advertise this. /// advertise this.
@@ -1743,20 +1758,31 @@ pub mod endpoint {
/// every `KEEP_ALIVE` keeps the path warm. The interval sits well under `MAX_IDLE` so /// 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 /// 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`. /// 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> { 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; use std::time::Duration;
// 8s idle (was 20s): a vanished client is declared dead within 8s instead of 20, so its let idle = idle.max(Duration::from_secs(1));
// session tears down promptly — which the Windows IDD-push path needs so a RECONNECT recreates let keep_alive = (idle / 2).min(Duration::from_secs(4));
// 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 mut t = quinn::TransportConfig::default(); let mut t = quinn::TransportConfig::default();
t.max_idle_timeout(Some( 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) Arc::new(t)
} }
@@ -1767,23 +1793,36 @@ pub mod endpoint {
.map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?; .map_err(|e| anyhow_result::Error::msg(format!("self-signed cert: {e}")))?;
let cert_der = rustls::pki_types::CertificateDer::from(cert.cert); let cert_der = rustls::pki_types::CertificateDer::from(cert.cert);
let key_der = rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()); 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) — /// 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 /// 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( pub fn server_with_identity(
addr: std::net::SocketAddr, addr: std::net::SocketAddr,
cert_pem: &str, cert_pem: &str,
key_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> { ) -> anyhow_result::Result<quinn::Endpoint> {
use rustls::pki_types::pem::PemObject; use rustls::pki_types::pem::PemObject;
let cert_der = rustls::pki_types::CertificateDer::from_pem_slice(cert_pem.as_bytes()) 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}")))?; .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()) 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}")))?; .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 /// Fixed ALPN for the punktfunk/1 QUIC handshake. Pinning it rejects a cross-protocol peer at the
@@ -1796,6 +1835,7 @@ pub mod endpoint {
cert_der: rustls::pki_types::CertificateDer<'static>, cert_der: rustls::pki_types::CertificateDer<'static>,
key_der: rustls::pki_types::PrivateKeyDer<'static>, key_der: rustls::pki_types::PrivateKeyDer<'static>,
addr: std::net::SocketAddr, addr: std::net::SocketAddr,
idle: std::time::Duration,
) -> anyhow_result::Result<quinn::Endpoint> { ) -> anyhow_result::Result<quinn::Endpoint> {
let _ = rustls::crypto::ring::default_provider().install_default(); let _ = rustls::crypto::ring::default_provider().install_default();
// Client auth is OFFERED but optional: a client that presents its self-signed // Client auth is OFFERED but optional: a client that presents its self-signed
@@ -1810,7 +1850,7 @@ pub mod endpoint {
let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg) let quic_cfg = quinn::crypto::rustls::QuicServerConfig::try_from(rustls_cfg)
.map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?; .map_err(|e| anyhow_result::Error::msg(format!("quic server config: {e}")))?;
let mut server_config = quinn::ServerConfig::with_crypto(Arc::new(quic_cfg)); 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)?) Ok(quinn::Endpoint::server(server_config, addr)?)
} }
+11 -5
View File
@@ -66,12 +66,18 @@ impl MediaClass {
} }
} }
/// Whether DSCP/QoS marking is enabled (`PUNKTFUNK_DSCP=1`). Off by default. /// Whether DSCP/QoS marking is enabled. Default **on for Android**, **off elsewhere**: on Wi-Fi
/// (where most Android clients live) access points commonly map DSCP to WMM access categories, so
/// tagging the video/audio sockets can win real airtime priority against other traffic on the link;
/// on the wired paths the other clients use it's rarely honoured and some paths bleach or reject
/// marked packets, so it stays opt-in there. `PUNKTFUNK_DSCP` overrides either way — `1`/`true`/`on`
/// forces it on, `0`/`false`/`off` forces it off (e.g. to rule QoS out while debugging a flaky AP).
pub(crate) fn dscp_enabled() -> bool { pub(crate) fn dscp_enabled() -> bool {
matches!( match std::env::var("PUNKTFUNK_DSCP").as_deref() {
std::env::var("PUNKTFUNK_DSCP").as_deref(), Ok("1") | Ok("true") | Ok("on") => true,
Ok("1") | Ok("true") | Ok("on") Ok("0") | Ok("false") | Ok("off") => false,
) _ => cfg!(target_os = "android"),
}
} }
/// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op /// Best-effort: tag `socket`'s outgoing packets for prioritized delivery of its media class. A no-op
+18 -2
View File
@@ -416,7 +416,14 @@ impl UdpTransport {
/// Bind `local` and `connect` to `peer`, so `send`/`recv` need no address and the /// 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. /// kernel filters to this peer. Non-blocking, matching the [`Transport`] contract.
pub fn connect(local: &str, peer: &str) -> std::io::Result<Self> { 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)?; socket.connect(peer)?;
super::qos::grow_socket_buffers(&socket); super::qos::grow_socket_buffers(&socket);
// The native data plane is video-dominant — tag it as the video class (opt-in via // 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, fallback_peer: &str,
punch_timeout: std::time::Duration, punch_timeout: std::time::Duration,
) -> std::io::Result<(Self, bool)> { ) -> 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))?; socket.set_read_timeout(Some(punch_timeout))?;
let deadline = std::time::Instant::now() + punch_timeout; let deadline = std::time::Instant::now() + punch_timeout;
let mut buf = [0u8; 64]; let mut buf = [0u8; 64];
+4 -3
View File
@@ -20,8 +20,9 @@ platform-facing around it.
- **Per-client virtual displays at the exact WxH@Hz.** Linux uses per-compositor backends — **KWin**, - **Per-client virtual displays at the exact WxH@Hz.** Linux uses per-compositor backends — **KWin**,
**gamescope**, **Mutter**, and **Sway/wlroots**; Windows uses its own all-Rust IddCx virtual display, **gamescope**, **Mutter**, and **Sway/wlroots**; Windows uses its own all-Rust IddCx virtual display,
even on the secure desktop (UAC / lock screen). even on the secure desktop (UAC / lock screen).
- **GPU zero-copy capture → encode.** dmabuf → CUDA/Vulkan → NVENC on Linux; DXGI/WGC → GPU encode on - **GPU zero-copy capture → encode.** dmabuf → CUDA/Vulkan → NVENC on Linux; on Windows the host
Windows. Encoders auto-select by GPU vendor: **NVENC** (NVIDIA), **VAAPI** (Linux AMD/Intel), pushes frames straight into its own IDD (sealed IDD-push, no screen-scraping) → GPU encode.
Encoders auto-select by GPU vendor: **NVENC** (NVIDIA), **VAAPI** (Linux AMD/Intel),
**AMF/QSV** (Windows AMD/Intel), or software H.264 as a floor. HDR/10-bit and HEVC 4:4:4 supported. **AMF/QSV** (Windows AMD/Intel), or software H.264 as a floor. HDR/10-bit and HEVC 4:4:4 supported.
- **Input injection.** Mouse/keyboard (libei / gamescope EIS / wlr / Windows SendInput) and virtual - **Input injection.** Mouse/keyboard (libei / gamescope EIS / wlr / Windows SendInput) and virtual
**gamepads** — Xbox 360/One, DualSense, DualShock 4 — with rumble and HID feedback back-channels. **gamepads** — Xbox 360/One, DualSense, DualShock 4 — with rumble and HID feedback back-channels.
@@ -70,7 +71,7 @@ src/
main.rs CLI + subcommand dispatch main.rs CLI + subcommand dispatch
config.rs · session_plan.rs · session_tuning.rs · pipeline.rs session setup + the frame pipeline config.rs · session_plan.rs · session_tuning.rs · pipeline.rs session setup + the frame pipeline
vdisplay/ per-compositor virtual outputs (kwin · gamescope · mutter · wlroots) vdisplay/ per-compositor virtual outputs (kwin · gamescope · mutter · wlroots)
capture/ · capture.rs screen/dmabuf capture (+ Windows DXGI/WGC) capture/ · capture.rs screen/dmabuf capture (+ Windows IDD-push)
encode/ · encode.rs per-GPU encoders (nvenc · vaapi · ffmpeg_win (AMF/QSV) · sw) encode/ · encode.rs per-GPU encoders (nvenc · vaapi · ffmpeg_win (AMF/QSV) · sw)
zerocopy/ dmabuf → CUDA → NVENC bridges (EGL/GL tiled, Vulkan LINEAR) zerocopy/ dmabuf → CUDA → NVENC bridges (EGL/GL tiled, Vulkan LINEAR)
inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4) inject/ · inject.rs input backends (libei · wlr · uinput gamepads · UHID DualSense/DS4)
+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 /// 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 /// HDR (HEVC Main10 / PQ) for a title — host-wide today ([`crate::gamestream::host_hdr_capable`]); when
/// true, Moonlight offers its per-app HDR toggle. /// 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 { pub fn applist_xml() -> String {
let hdr = u8::from(crate::gamestream::host_hdr_capable()); let hdr = u8::from(crate::gamestream::host_hdr_capable());
let mut xml = 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() { for app in catalog() {
xml.push_str(&format!( 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), xml_escape(&app.title),
app.id app.id
)); ));
} }
xml.push_str("</root>\n"); xml.push_str("</root>");
xml xml
} }
@@ -249,4 +257,30 @@ mod tests {
assert!(xml.starts_with("<?xml")); assert!(xml.starts_with("<?xml"));
assert_eq!(xml.matches("<App>").count(), xml.matches("</App>").count()); 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). /// 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). /// `None` if the address could not be captured (then RTSP falls back to launch-present only).
pub peer_ip: Option<std::net::IpAddr>, 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. /// Shared control-plane state used as the axum app state.
+130 -4
View File
@@ -126,15 +126,59 @@ async fn h_launch(
peer: Option<Extension<PeerCertFingerprint>>, peer: Option<Extension<PeerCertFingerprint>>,
addr: Option<Extension<PeerAddr>>, addr: Option<Extension<PeerAddr>>,
Query(q): Query<HashMap<String, String>>, Query(q): Query<HashMap<String, String>>,
) -> impl IntoResponse { ) -> Response {
if !peer_is_paired(&peer, &st) { if !peer_is_paired(&peer, &st) {
tracing::warn!("launch rejected — client is not paired"); 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) { match launch(&st, &q) {
Ok(mut session) => { Ok(mut session) => {
// Bind the (unauthenticated) RTSP/UDP media plane to this paired client's source IP. // 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.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); *st.launch.lock().unwrap() = Some(session);
tracing::info!( tracing::info!(
w = session.width, w = session.width,
@@ -144,11 +188,11 @@ async fn h_launch(
"launch — session created; RTSP at rtsp://{}:{RTSP_PORT}", "launch — session created; RTSP at rtsp://{}:{RTSP_PORT}",
st.host.local_ip st.host.local_ip
); );
xml(session_url_xml(&st, "gamesession")) xml(session_url_xml(&st, "gamesession")).into_response()
} }
Err(e) => { Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "launch failed"); tracing::warn!(error = %format!("{e:#}"), "launch failed");
xml(error_xml()) xml(error_xml()).into_response()
} }
} }
} }
@@ -211,6 +255,7 @@ fn launch(_st: &AppState, q: &HashMap<String, String>) -> Result<LaunchSession>
fps, fps,
appid, 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)) 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 { fn session_url_xml(st: &AppState, tag: &str) -> String {
format!( 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", "<?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" "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
));
}
} }
+219 -52
View File
@@ -246,15 +246,34 @@ fn open_gs_virtual_source(
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
// A client is (re)connecting → cancel any pending TV-session restore (review #3).
crate::vdisplay::cancel_pending_tv_restore();
let active = crate::vdisplay::detect_active_session(); let active = crate::vdisplay::detect_active_session();
// A4: fold any compositor-instance change (idle-time Game↔Desktop switch) into the epoch
// before acquiring, so a GameStream reconnect never reuses a dead-instance node.
crate::vdisplay::observe_session_instance(&active);
crate::vdisplay::apply_session_env(&active); crate::vdisplay::apply_session_env(&active);
// Dedicated game session (B0): a GameStream app whose launch RESOLVES to a command (library
// id / apps.json command), under `game_session=dedicated` with gamescope available, gets its
// own headless gamescope spawn at the client mode — same routing as the native plane. Gate on
// the resolved command so an unresolvable entry falls back to auto routing (review #9).
let has_launch = crate::library::resolve_session_launch(
app.and_then(|a| a.library_id.as_deref()),
app.and_then(|a| a.cmd.as_deref()),
)
.is_some();
if crate::vdisplay::wants_dedicated_game_session(has_launch) {
crate::vdisplay::apply_input_env(crate::vdisplay::Compositor::Gamescope, true);
crate::vdisplay::Compositor::Gamescope
} else {
let c = crate::vdisplay::compositor_for_kind(active.kind) let c = crate::vdisplay::compositor_for_kind(active.kind)
.map(Ok) .map(Ok)
.unwrap_or_else(crate::vdisplay::detect) .unwrap_or_else(crate::vdisplay::detect)
.context("detect compositor")?; .context("detect compositor")?;
crate::vdisplay::apply_input_env(c); crate::vdisplay::apply_input_env(c, false);
c c
} }
}
}; };
let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?; let mut vd = crate::vdisplay::open(compositor).context("open virtual display")?;
// Carry the resolved launch command on the backend instance (per-session) rather than a // Carry the resolved launch command on the backend instance (per-session) rather than a
@@ -286,12 +305,18 @@ fn open_gs_virtual_source(
std::sync::atomic::AtomicBool::new(false), std::sync::atomic::AtomicBool::new(false),
)) ))
}); });
let vout = vd let vout = crate::vdisplay::registry::acquire(
.create(punktfunk_core::Mode { &mut vd,
punktfunk_core::Mode {
width: cfg.width, width: cfg.width,
height: cfg.height, height: cfg.height,
refresh_hz: cfg.fps, refresh_hz: cfg.fps,
}) },
// 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")?; .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 // 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 // Windows IDD-push path this proactively enables advanced color on the virtual display so a Main10
@@ -397,6 +422,68 @@ fn sendmmsg_all(sock: &UdpSocket, pkts: &[Vec<u8>]) -> std::io::Result<()> {
Ok(()) 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 /// 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 /// `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 /// (microburst shaping at chunk granularity — a real link drops line-rate bursts; the encode
@@ -414,8 +501,14 @@ fn spawn_sender(
// Transmit thread: above-normal, matching the native path's send thread (includes the // 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). // Windows session tuning/MMCSS this used to call directly; adds the Linux nice -5).
crate::punktfunk1::boost_thread_priority(false); crate::punktfunk1::boost_thread_priority(false);
// Chunk pacing: 16 packets per burst, bursts spread across the send budget. // Chunk pacing: spread the frame's packets across the send budget in a BOUNDED number
const PACE_CHUNK: usize = 16; // 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 budget = frame_interval.mul_f32(0.75);
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let mut sent: u64 = 0; let mut sent: u64 = 0;
@@ -434,17 +527,21 @@ fn spawn_sender(
if n == 0 { if n == 0 {
continue; 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(); 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) { if let Err(e) = sendmmsg_all(&sock, chunk) {
tracing::info!(error = %e, sent, "video: client unreachable — stopping stream"); tracing::info!(error = %e, sent, "video: client unreachable — stopping stream");
running.store(false, Ordering::SeqCst); running.store(false, Ordering::SeqCst);
return; return;
} }
sent += chunk.len() as u64; sent += chunk.len() as u64;
// Sleep toward the next chunk's deadline; skip sub-500µs sleeps (jitter). // Sleep toward the next step's deadline; skip sub-500µs sleeps (jitter).
let target = start + per_chunk.mul_f64((i + 1) as f64); let target = start + per_step.mul_f64((i + 1) as f64);
if let Some(ahead) = target.checked_duration_since(Instant::now()) { if let Some(ahead) = target.checked_duration_since(Instant::now()) {
if ahead >= Duration::from_micros(500) { if ahead >= Duration::from_micros(500) {
std::thread::sleep(ahead); std::thread::sleep(ahead);
@@ -518,7 +615,7 @@ fn stream_body(
.ok() .ok()
.and_then(|v| v.parse().ok()) .and_then(|v| v.parse().ok())
.unwrap_or(20); .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 // 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 // compositor produced no new one. Compositors only emit frames on damage, so a static or
@@ -538,9 +635,15 @@ fn stream_body(
let mut sent_batches: u64 = 0; let mut sent_batches: u64 = 0;
let mut dropped_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 // Three-stage pipeline so FEC packetization never blocks encode: `encode loop → [raw AUs] →
// slow send can buffer one frame while the next encodes; beyond that the NEWEST batch is // packetizer (FEC/RS) → [wire batch] → paced sender`, each stage on its own thread joined by a
// dropped (the client recovers via FEC/RFI) rather than ever stalling the encode loop. // 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); let (batch_tx, batch_rx) = std::sync::mpsc::sync_channel::<PacketBatch>(2);
spawn_sender( spawn_sender(
sock.try_clone().context("clone video socket")?, sock.try_clone().context("clone video socket")?,
@@ -549,12 +652,14 @@ fn stream_body(
running.clone(), running.clone(),
drop_pct, 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, // 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). // to pinpoint stalls. `unique` counts genuinely-new captured frames (vs re-encoded holds).
let perf = crate::config::config().perf; let perf = crate::config::config().perf;
let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut mx_pkts, mut uniq) = let (mut mx_cap, mut mx_enc, mut mx_pkt, mut mx_send, mut uniq) =
(0u128, 0u128, 0u128, 0u128, 0usize, 0u32); (0u128, 0u128, 0u128, 0u128, 0u32);
// Web-console stats accumulation (active when `perf` OR a capture is armed): per-stage vectors // 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 // 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. // dropped-frame count for delta computation, and the registration id cached on the first sample.
@@ -566,7 +671,6 @@ fn stream_body(
let mut sid: Option<u32> = None; 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>) = 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()); (Vec::new(), Vec::new(), Vec::new(), Vec::new());
let mut bytes_win: u64 = 0;
let mut last_dropped_batches: u64 = 0; let mut last_dropped_batches: u64 = 0;
// Absolute next-frame deadline — the single pacing clock for the loop. // Absolute next-frame deadline — the single pacing clock for the loop.
let mut next_frame = Instant::now(); let mut next_frame = Instant::now();
@@ -580,6 +684,22 @@ fn stream_body(
const MAX_REBUILDS: u32 = 5; const MAX_REBUILDS: u32 = 5;
let mut rebuilds: u32 = 0; 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) { while running.load(Ordering::SeqCst) {
let tick = Instant::now(); let tick = Instant::now();
// Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is // Measure per-stage timing when `PUNKTFUNK_PERF` is set OR a web-console stats capture is
@@ -645,6 +765,7 @@ fn stream_body(
.context("reopen encoder after rebuild")?; .context("reopen encoder after rebuild")?;
supports_rfi = enc.caps().supports_rfi; supports_rfi = enc.caps().supports_rfi;
enc.request_keyframe(); enc.request_keyframe();
last_keyframe = Some(Instant::now());
next_frame = Instant::now(); next_frame = Instant::now();
tracing::info!("gamestream: source rebuilt — stream continues"); tracing::info!("gamestream: source rebuilt — stream continues");
continue; continue;
@@ -654,58 +775,74 @@ fn stream_body(
// Honor a client recovery request. Prefer reference-frame invalidation (the encoder // 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 // 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. // 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() { if let Some((first, last)) = rfi_range.lock().unwrap().take() {
// Prefer reference-frame invalidation when the encoder supports it (no costly IDR // 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)) { 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. // immediately instead of waiting for the next GOP boundary.
if force_idr.swap(false, Ordering::SeqCst) { if force_idr.swap(false, Ordering::SeqCst) {
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(); enc.request_keyframe();
last_keyframe = Some(now);
} else {
tracing::debug!("video: keyframe request coalesced (IDR still in flight)");
}
} }
enc.submit(&frame).context("encoder submit")?; enc.submit(&frame).context("encoder submit")?;
let t_enc = tick.elapsed(); let t_enc = tick.elapsed();
// 90 kHz RTP timestamp from wall-clock, so a variable capture rate stays correct. // 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 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")? { while let Some(au) = enc.poll().context("encoder poll")? {
let ft = if au.keyframe { let ft = if au.keyframe {
FrameType::Idr FrameType::Idr
} else { } else {
FrameType::P FrameType::P
}; };
batch.extend(pk.packetize(&au.data, ft, ts)); aus.push((au.data, ft));
} }
let t_pkt = tick.elapsed(); let t_pkt = tick.elapsed();
// Hand the frame's packets to the send thread; never block here. A full queue means // Hand the frame's AUs to the pipeline; never block here. A full queue means the pipeline
// the sender is behind — drop this batch (FEC/RFI covers the client) and keep encoding. // (packetizer, or the paced sender behind it) is behind — drop this frame (FEC/RFI covers the
let n = batch.len(); // client) and keep encoding, so a downstream stall can never cap the encode rate.
// Goodput this window = bytes actually queued to the sender (a dropped batch never reaches if !aus.is_empty() {
// the wire, so it's excluded). Summed only when measuring, to keep the idle path free. match raw_tx.try_send(RawFrame { aus, ts }) {
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) {
Ok(()) => { Ok(()) => {
sent_batches += 1; sent_batches += 1;
bytes_win += batch_bytes;
} }
Err(std::sync::mpsc::TrySendError::Full(_)) => { Err(std::sync::mpsc::TrySendError::Full(_)) => {
dropped_batches += 1; dropped_batches += 1;
recover_after_drop = true; // re-anchor the reference chain on the next frame
if dropped_batches.is_power_of_two() { 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(_)) => { Err(std::sync::mpsc::TrySendError::Disconnected(_)) => {
break; // sender exited (client gone) break; // packetizer/sender exited (client gone)
} }
} }
} }
@@ -713,26 +850,33 @@ fn stream_body(
let t_send = tick.elapsed(); let t_send = tick.elapsed();
let cap_us = t_cap.as_micros(); let cap_us = t_cap.as_micros();
let enc_us = (t_enc - t_cap).as_micros(); let enc_us = (t_enc - t_cap).as_micros();
let pkt_us = (t_pkt - t_enc).as_micros(); // `poll` = drain the encoder's AUs; `enqueue` = hand-off to the pipeline. FEC/packetize
let send_us = (t_send - t_pkt).as_micros(); // 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_cap = mx_cap.max(cap_us);
mx_enc = mx_enc.max(enc_us); mx_enc = mx_enc.max(enc_us);
mx_pkt = mx_pkt.max(pkt_us); mx_pkt = mx_pkt.max(poll_us);
mx_send = mx_send.max(send_us); mx_send = mx_send.max(enqueue_us);
mx_pkts = mx_pkts.max(n);
v_cap.push(cap_us as u32); v_cap.push(cap_us as u32);
v_enc.push(enc_us as u32); v_enc.push(enc_us as u32);
v_pkt.push(pkt_us as u32); v_pkt.push(poll_us as u32);
v_send.push(send_us as u32); v_send.push(enqueue_us as u32);
} }
fps_count += 1; fps_count += 1;
if fps_t.elapsed() >= Duration::from_secs(1) { if fps_t.elapsed() >= Duration::from_secs(1) {
let secs = fps_t.elapsed().as_secs_f64(); 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 { if perf {
// Max µs/stage this second: cap=drain channel, enc=submit (zero-copy device // Max µs/stage this second on the ENCODE loop: cap=drain channel, enc=submit
// copy + NVENC), pkt=poll+FEC+packetize, send=paced packet send. `uniq`=new // (zero-copy device copy + NVENC), pkt=poll (AU drain), send=enqueue to the pipeline.
// captured frames (vs re-encoded). `pkts`=max packets in one frame (IDR spike). // 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!( tracing::info!(
fps = fps_count, fps = fps_count,
uniq, uniq,
@@ -740,7 +884,6 @@ fn stream_body(
pkt_us = mx_pkt, pkt_us = mx_pkt,
send_us = mx_send, send_us = mx_send,
cap_us = mx_cap, cap_us = mx_cap,
max_pkts = mx_pkts,
"video: streaming (perf)" "video: streaming (perf)"
); );
} else { } else {
@@ -753,7 +896,7 @@ fn stream_body(
} }
// Web-console capture: build the aggregated sample. The host send side exposes no // 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 // 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() { if stats.is_armed() {
let session_id = *sid.get_or_insert_with(|| { let session_id = *sid.get_or_insert_with(|| {
stats.register_session( stats.register_session(
@@ -792,7 +935,7 @@ fn stream_body(
], ],
fps: (uniq as f64 / secs) as f32, fps: (uniq as f64 / secs) as f32,
repeat_fps: (fps_count.saturating_sub(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, bitrate_kbps: cfg.bitrate_kbps,
frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32, frames_dropped: dropped_batches.saturating_sub(last_dropped_batches) as u32,
packets_dropped: 0, packets_dropped: 0,
@@ -805,13 +948,11 @@ fn stream_body(
mx_enc = 0; mx_enc = 0;
mx_pkt = 0; mx_pkt = 0;
mx_send = 0; mx_send = 0;
mx_pkts = 0;
uniq = 0; uniq = 0;
v_cap.clear(); v_cap.clear();
v_enc.clear(); v_enc.clear();
v_pkt.clear(); v_pkt.clear();
v_send.clear(); v_send.clear();
bytes_win = 0;
last_dropped_batches = dropped_batches; last_dropped_batches = dropped_batches;
fps_count = 0; fps_count = 0;
fps_t = Instant::now(); fps_t = Instant::now();
@@ -889,4 +1030,30 @@ mod tests {
assert_eq!(got, 3 * PER_FRAME); assert_eq!(got, 3 * PER_FRAME);
assert!(running.load(Ordering::SeqCst), "no spurious client-gone"); 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, allow_pairing: true,
pairing_pin: None, pairing_pin: None,
paired_store: 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. // 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)> { fn parse_serve(args: &[String]) -> Result<(mgmt::Options, punktfunk1::NativeServe, bool)> {
let mut opts = mgmt::Options::default(); let mut opts = mgmt::Options::default();
let mut native_port: u16 = 9777; // the native plane always runs now 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 open = false;
let mut gamestream = false; let mut gamestream = false;
// Did the operator pin the mgmt bind themselves? If not, we LAN-expose the read surface below so // 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() .parse()
.map_err(|_| anyhow::anyhow!("bad --native-port (want a port number)"))? .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 // Opt into the GameStream/Moonlight-compat planes (off by default — they carry the
// inherent on-path #5/#9 weaknesses; only for a trusted LAN). // inherent on-path #5/#9 weaknesses; only for a trusted LAN).
"--gamestream" | "--moonlight" => gamestream = true, "--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 // 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. // assuming the default). `opts.bind.port()` is the real port even if the operator moved it.
mgmt_port: opts.bind.port(), mgmt_port: opts.bind.port(),
data_port,
}; };
Ok((opts, native, gamestream)) Ok((opts, native, gamestream))
} }
@@ -703,6 +732,10 @@ SERVE OPTIONS:
reuse, security-review #5/#9); enable only on a TRUSTED LAN 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 no-op (the native punktfunk/1 plane always runs in `serve` now)
--native-port <PORT> native QUIC port (default 9777) --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 — --open disable mandatory native pairing (default: pairing REQUIRED —
an open host any LAN device can stream from is insecure) 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-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 --max-concurrent <N> stream at most N sessions at once (NVENC bound); overflow waits
in the accept queue; 0 = unlimited (default: 4) 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 --allow-tofu also accept UNPAIRED clients (trust-on-first-use) and advertise
pair=optional. Default: pairing REQUIRED — the host rejects pair=optional. Default: pairing REQUIRED — the host rejects
unpaired clients and logs a 4-digit pairing PIN at startup; unpaired clients and logs a 4-digit pairing PIN at startup;
+387
View File
@@ -156,6 +156,11 @@ fn api_router_parts() -> (Router<Arc<MgmtState>>, utoipa::openapi::OpenApi) {
.routes(routes!(list_compositors)) .routes(routes!(list_compositors))
.routes(routes!(list_gpus)) .routes(routes!(list_gpus))
.routes(routes!(set_gpu_preference)) .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_status))
.routes(routes!(get_local_summary)) .routes(routes!(get_local_summary))
.routes(routes!(list_paired_clients)) .routes(routes!(list_paired_clients))
@@ -210,6 +215,7 @@ pub fn openapi_json() -> String {
tags( tags(
(name = "host", description = "Host identity, capabilities, and liveness"), (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 = "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 = "clients", description = "Paired Moonlight client management"),
(name = "pairing", description = "Pairing PIN delivery (the out-of-band half of the GameStream pairing handshake)"), (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"), (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, pin_pending: bool,
/// Native pairing knocks awaiting the operator's approval (count only). /// Native pairing knocks awaiting the operator's approval (count only).
pending_approvals: u32, 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. /// A paired (certificate-pinned) Moonlight client.
@@ -954,6 +964,308 @@ async fn set_gpu_preference(ApiJson(req): ApiJson<SetGpuPreference>) -> Response
Json(gpu_state()).into_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(),
"game_session".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, store.game_session());
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 /// Live host status
#[utoipa::path( #[utoipa::path(
get, get,
@@ -1026,6 +1338,11 @@ async fn get_local_summary(State(st): State<Arc<MgmtState>>) -> Json<LocalSummar
native_paired_clients, native_paired_clients,
pin_pending: st.app.pairing.pin.awaiting_pin(), pin_pending: st.app.pairing.pin.awaiting_pin(),
pending_approvals, pending_approvals,
kept_displays: crate::vdisplay::registry::snapshot()
.displays
.iter()
.filter(|d| d.state == "lingering" || d.state == "pinned")
.count() as u32,
}) })
} }
@@ -2257,6 +2574,7 @@ mod tests {
fps: 120, fps: 120,
appid: 1, appid: 1,
peer_ip: None, peer_ip: None,
owner_fp: None,
}); });
state.streaming.store(true, Ordering::SeqCst); state.streaming.store(true, Ordering::SeqCst);
@@ -2383,6 +2701,7 @@ mod tests {
fps: 60, fps: 60,
appid: 1, appid: 1,
peer_ip: None, peer_ip: None,
owner_fp: None,
}); });
let del = axum::http::Request::delete("/api/v1/session") let del = axum::http::Request::delete("/api/v1/session")
@@ -2473,6 +2792,74 @@ mod tests {
.unwrap() .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] #[tokio::test]
async fn native_pairing_arm_show_and_unpair() { async fn native_pairing_arm_show_and_unpair() {
let np = Arc::new( let np = Arc::new(
+394 -43
View File
@@ -75,6 +75,41 @@ pub struct Punktfunk1Options {
pub pairing_pin: Option<String>, pub pairing_pin: Option<String>,
/// Paired-clients store path override (tests); `None` = the default config path. /// Paired-clients store path override (tests); `None` = the default config path.
pub paired_store: Option<std::path::PathBuf>, 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. /// 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 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). /// the same host IP (the unified `serve` always runs the mgmt API, so this is its bind port).
pub mgmt_port: u16, 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, /// 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`. /// overflow clients wait in the accept queue. Override with `--max-concurrent`.
pub(crate) const DEFAULT_MAX_CONCURRENT: usize = 4; 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 { pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
Punktfunk1Options { Punktfunk1Options {
port: cfg.port, port: cfg.port,
@@ -165,6 +214,8 @@ pub(crate) fn native_serve_opts(cfg: &NativeServe) -> Punktfunk1Options {
allow_pairing: false, allow_pairing: false,
pairing_pin: None, pairing_pin: None,
paired_store: 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)")?; .context("load host identity (~/.config/punktfunk)")?;
let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem) let fingerprint = endpoint::fingerprint_of_pem(&identity.cert_pem)
.map_err(|e| anyhow!("cert fingerprint: {e}"))?; .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(), ([0, 0, 0, 0], opts.port).into(),
&identity.cert_pem, &identity.cert_pem,
&identity.key_pem, &identity.key_pem,
opts.idle_timeout.unwrap_or(endpoint::DEFAULT_IDLE_TIMEOUT),
) )
.map_err(|e| anyhow!("QUIC server endpoint: {e}"))?; .map_err(|e| anyhow!("QUIC server endpoint: {e}"))?;
tracing::info!( tracing::info!(
@@ -233,6 +285,9 @@ pub(crate) async fn serve(
// restores the box's autologin gaming session on idle, not per-disconnect — see // restores the box's autologin gaming session on idle, not per-disconnect — see
// `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it. // `vdisplay::restore_managed_session`). Held for serve()'s lifetime; dropping it stops it.
let _restore_worker = crate::vdisplay::start_restore_worker(); let _restore_worker = crate::vdisplay::start_restore_worker();
// A3: recover a TV takeover stranded by a crashed previous host instance (persisted to
// $XDG_RUNTIME_DIR) — schedule a restore after a reconnect grace. No-op on a clean start.
crate::vdisplay::restore_takeover_on_startup();
// Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com / // Host-lifetime cover-art warmer: fetches + caches GOG/Xbox cover art (no-auth api.gog.com /
// displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never // displaycatalog) off the hot path so `all_games()` (the library list + launch resolve) never
// blocks on the network. A no-op on a host whose stores all carry their own art. // blocks on the network. A no-op on a host whose stores all carry their own art.
@@ -341,6 +396,18 @@ pub(crate) async fn serve(
/// connects and never finishes the handshake would otherwise wedge the host for everyone. /// 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); 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 /// 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 /// (`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. /// link (e.g. after a speed test) requests an explicit rate instead.
@@ -585,10 +652,10 @@ async fn serve_session(
// the `handshake` future re-decodes for the real session — a few dozen bytes, negligible. // the `handshake` future re-decodes for the real session — a few dozen bytes, negligible.
let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?; let gate_hello = Hello::decode(&first).map_err(|e| anyhow!("Hello decode: {e:?}"))?;
anyhow::ensure!( anyhow::ensure!(
gate_hello.abi_version == punktfunk_core::ABI_VERSION, gate_hello.abi_version == punktfunk_core::WIRE_VERSION,
"ABI mismatch: client {} host {}", "wire version mismatch: client {} host {}",
gate_hello.abi_version, gate_hello.abi_version,
punktfunk_core::ABI_VERSION punktfunk_core::WIRE_VERSION
); );
let fp = endpoint::peer_fingerprint(&conn); let fp = endpoint::peer_fingerprint(&conn);
let known = fp let known = fp
@@ -651,13 +718,14 @@ async fn serve_session(
let source = opts.source; let source = opts.source;
let frames = opts.frames; let frames = opts.frames;
let data_port = opts.data_port;
let handshake = async { 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!( anyhow::ensure!(
hello.abi_version == punktfunk_core::ABI_VERSION, hello.abi_version == punktfunk_core::WIRE_VERSION,
"ABI mismatch: client {} host {}", "wire version mismatch: client {} host {}",
hello.abi_version, hello.abi_version,
punktfunk_core::ABI_VERSION punktfunk_core::WIRE_VERSION
); );
// The pairing gate (require_pairing → paired? else park for delegated approval) ran above, // The pairing gate (require_pairing → paired? else park for delegated approval) ran above,
// before this future, so a client reaching here is paired (or the host is `--open`). // before this future, so a client reaching here is paired (or the host is `--open`).
@@ -684,6 +752,74 @@ async fn serve_session(
"video codec negotiated" "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) crate::encode::validate_dimensions(codec, hello.mode.width, hello.mode.height)
.context("client-requested mode")?; .context("client-requested mode")?;
@@ -693,8 +829,23 @@ async fn serve_session(
let compositor = match source { let compositor = match source {
Punktfunk1Source::Virtual => { Punktfunk1Source::Virtual => {
let pref = hello.compositor; let pref = hello.compositor;
// Dedicated game session (B0): a launching client under `game_session=dedicated`
// (gamescope available) gets its own headless gamescope spawn at the client mode. Gate on
// whether the launch id actually RESOLVES to a command in the host's library — an unknown
// id must fall back to normal auto routing, not a blank "sleep infinity" gamescope
// (review #9). (dedicated is Linux-only; the resolver is the non-Windows launch_command.)
#[cfg(not(target_os = "windows"))]
let has_resolvable_launch = hello
.launch
.as_deref()
.and_then(crate::library::launch_command)
.is_some();
#[cfg(target_os = "windows")]
let has_resolvable_launch = false;
let dedicated =
crate::vdisplay::wants_dedicated_game_session(has_resolvable_launch);
Some( Some(
tokio::task::spawn_blocking(move || resolve_compositor(pref)) tokio::task::spawn_blocking(move || resolve_compositor(pref, dedicated))
.await .await
.context("resolve compositor task")??, .context("resolve compositor task")??,
) )
@@ -797,15 +948,17 @@ async fn serve_session(
"encode chroma" "encode chroma"
); );
// Reserve a UDP port for the data plane (bind, read it back, rebind in UdpTransport). // Reserve the data-plane UDP socket up front and HOLD it through streaming (no
let probe = std::net::UdpSocket::bind("0.0.0.0:0")?; // bind→read→drop→rebind window a concurrent session could race for a fixed port). A fixed
let udp_port = probe.local_addr()?.port(); // `--data-port` yields `direct = true` (stream straight to the client's reported address,
drop(probe); // 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]; let mut key = [0u8; 16];
rand::thread_rng().fill_bytes(&mut key); rand::thread_rng().fill_bytes(&mut key);
let welcome = Welcome { let welcome = Welcome {
abi_version: punktfunk_core::ABI_VERSION, abi_version: punktfunk_core::WIRE_VERSION,
udp_port, udp_port,
mode: hello.mode, mode: hello.mode,
// The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption. // The post-GameStream point of punktfunk/1: Leopard GF(2¹⁶) FEC + real encryption.
@@ -860,9 +1013,11 @@ async fn serve_session(
let start = Start::decode(&io::read_msg(&mut recv).await?) let start = Start::decode(&io::read_msg(&mut recv).await?)
.map_err(|e| anyhow!("Start decode: {e:?}"))?; .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) tokio::time::timeout(HANDSHAKE_TIMEOUT, handshake)
.await .await
.map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??; .map_err(|_| anyhow!("handshake timed out after {HANDSHAKE_TIMEOUT:?}"))??;
@@ -1046,15 +1201,50 @@ async fn serve_session(
// Stop signal: stream duration elapsed or the client went away. // Stop signal: stream duration elapsed or the client went away.
let stop = Arc::new(AtomicBool::new(false)); 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 stop = stop.clone();
let quit = quit.clone();
let conn = conn.clone(); let conn = conn.clone();
tokio::spawn(async move { 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); 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 // 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 // → 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 // (no PipeWire audio, spawn error): the session continues without audio — and a spawn
@@ -1153,6 +1343,7 @@ async fn serve_session(
crate::encode::ChromaFormat::Yuv420 crate::encode::ChromaFormat::Yuv420
}; };
let stop_stream = stop.clone(); 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 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 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 // Per-AU host-timing emission (0xCF): only when the client advertised the cap bit. All
@@ -1168,29 +1359,41 @@ async fn serve_session(
.unwrap_or_else(|| conn.remote_address().ip().to_string()); .unwrap_or_else(|| conn.remote_address().ip().to_string());
let result: Result<()> = async { let result: Result<()> = async {
tokio::task::spawn_blocking(move || -> Result<()> { tokio::task::spawn_blocking(move || -> Result<()> {
// Wait briefly for the client to hole-punch our data port, then stream to its OBSERVED // Bring up the (already-bound) data-plane socket. Default: hole-punch — wait briefly
// source — so video traverses a NAT / stateful inter-VLAN firewall (the client and host // for the client's punch, then stream to its OBSERVED source, so video traverses a
// can be on different subnets; control + side planes ride the client-initiated QUIC, but // NAT / stateful inter-VLAN firewall (control + side planes ride the client-initiated
// the raw video UDP needs the client to open the path first). Falls back to the // QUIC, but the raw video UDP needs the client to open the path first); falls back to
// client-reported address for clients that don't punch (flat-LAN, unchanged). // the reported address for clients that don't punch (flat-LAN, unchanged). With a fixed
let (transport, punched) = match UdpTransport::connect_via_punch( // `--data-port` (`direct`), skip the punch-wait and stream straight to the reported
&format!("0.0.0.0:{udp_port}"), // 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(), &client_udp.to_string(),
std::time::Duration::from_millis(2500), std::time::Duration::from_millis(2500),
) { )
};
let (transport, punched) = match bound {
Ok(v) => v, Ok(v) => v,
Err(e) => { Err(e) => {
// Surface the failure here directly: a data-plane bind error would otherwise be // 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). // 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"); return Err(anyhow::Error::new(e)).context("bind data plane");
} }
}; };
tracing::info!( tracing::info!(
%client_udp, %client_udp,
udp_port,
direct,
punched, punched,
"data plane bound (punched=true → streaming to the client's observed source; \ "data plane bound (direct=true → fixed --data-port, streaming to the reported \
false no hole-punch seen, using the reported address)" 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)) let mut session = Session::new(cfg, Box::new(transport))
.map_err(|e| anyhow!("host session: {e:?}"))?; .map_err(|e| anyhow!("host session: {e:?}"))?;
@@ -1212,6 +1415,7 @@ async fn serve_session(
mode, mode,
seconds, seconds,
stop: stop_stream, stop: stop_stream,
quit: quit_stream,
reconfig: reconfig_rx, reconfig: reconfig_rx,
keyframe: keyframe_rx, keyframe: keyframe_rx,
compositor, compositor,
@@ -2037,17 +2241,24 @@ fn pick_compositor(
/// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log /// [`pick_compositor`]): enumerate what's available, auto-detect the default, pick, and log
/// whether the explicit request was honored or fell back. Runs blocking probes — call off the /// whether the explicit request was honored or fell back. Runs blocking probes — call off the
/// async reactor (`spawn_blocking`). /// async reactor (`spawn_blocking`).
fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Compositor> { fn resolve_compositor(
pref: CompositorPref,
dedicated_launch: bool,
) -> Result<crate::vdisplay::Compositor> {
use crate::vdisplay::Compositor; use crate::vdisplay::Compositor;
// Windows has a single virtual-display backend (SudoVDA); vdisplay::open ignores the compositor // Windows has a single virtual-display backend (SudoVDA); vdisplay::open ignores the compositor
// arg there, so short-circuit the Linux session-detection state machine with a placeholder. // arg there, so short-circuit the Linux session-detection state machine with a placeholder.
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
let _ = pref; let _ = (pref, dedicated_launch);
Ok(Compositor::Kwin) Ok(Compositor::Kwin)
} }
#[cfg(not(target_os = "windows"))] #[cfg(not(target_os = "windows"))]
{ {
// A client is (re)connecting → cancel any pending TV-session restore so the box stays in the
// streamed session (covers the keep-alive REUSE reconnect, which skips create_managed_session's
// own cancel — review #3). No-op when nothing is pending.
crate::vdisplay::cancel_pending_tv_restore();
// Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed // Explicit operator override (legacy / CI / forcing a backend for a test) wins and is assumed
// to come with a hand-set env — don't retarget the process env in that case. // to come with a hand-set env — don't retarget the process env in that case.
let overridden = crate::config::config().compositor.is_some(); let overridden = crate::config::config().compositor.is_some();
@@ -2058,6 +2269,10 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
// every backend (video capture + input) this connect opens against the active session — // every backend (video capture + input) this connect opens against the active session —
// this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop. // this is the state machine that lets one host follow a Bazzite box across Gaming↔Desktop.
let active = crate::vdisplay::detect_active_session(); let active = crate::vdisplay::detect_active_session();
// A4: if the compositor instance changed since the last connect (an idle-time Game↔Desktop
// switch), bump the epoch + invalidate the old backend's kept displays so this connect never
// reuses a node id from the dead instance.
crate::vdisplay::observe_session_instance(&active);
crate::vdisplay::apply_session_env(&active); crate::vdisplay::apply_session_env(&active);
tracing::info!( tracing::info!(
active = ?active.kind, active = ?active.kind,
@@ -2066,6 +2281,18 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
); );
crate::vdisplay::compositor_for_kind(active.kind) crate::vdisplay::compositor_for_kind(active.kind)
}; };
// Dedicated game session (design/gamemode-and-dedicated-sessions.md B0): a launching session
// under `game_session=dedicated` (gamescope confirmed available) forces its OWN headless
// gamescope spawn at the client's mode, overriding the detected desktop/game-mode backend. The
// env was already retargeted above (for XDG_RUNTIME_DIR / the PipeWire daemon); we just pin the
// backend + input to the spawn sub-mode. Skipped under an explicit operator compositor pin.
if dedicated_launch && !overridden {
crate::vdisplay::apply_input_env(Compositor::Gamescope, true);
tracing::info!(
"dedicated game session — routing to a headless gamescope spawn at the client mode"
);
return Ok(Compositor::Gamescope);
}
let available = crate::vdisplay::available(); let available = crate::vdisplay::available();
let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| { let chosen = pick_compositor(pref, &available, detected).ok_or_else(|| {
anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)") anyhow!("no usable compositor (no live graphical session for this uid; set PUNKTFUNK_COMPOSITOR or start a desktop/gaming session)")
@@ -2073,7 +2300,7 @@ fn resolve_compositor(pref: CompositorPref) -> Result<crate::vdisplay::Composito
if !overridden { if !overridden {
// Point input at the same backend and resolve the gamescope sub-mode (managed where the // Point input at the same backend and resolve the gamescope sub-mode (managed where the
// session infra exists, attach to a foreign gamescope, else per-session bare spawn). // session infra exists, attach to a foreign gamescope, else per-session bare spawn).
crate::vdisplay::apply_input_env(chosen); crate::vdisplay::apply_input_env(chosen, false);
} }
let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect(); let avail_ids: Vec<&str> = available.iter().map(|c| c.id()).collect();
match Compositor::from_pref(pref) { match Compositor::from_pref(pref) {
@@ -2700,6 +2927,11 @@ fn session_watcher_loop(tx: std::sync::mpsc::Sender<SessionSwitch>, stop: Arc<At
break; break;
} }
let active = vdisplay::detect_active_session(); let active = vdisplay::detect_active_session();
// A4: bump the session epoch + invalidate the old backend the moment the compositor instance
// changes (kind change OR same-kind restart) — even for a same-kind restart the watcher won't
// signal a full SessionSwitch for. Self-dedupes; the debounced SessionSwitch below still drives
// the in-place rebuild.
vdisplay::observe_session_instance(&active);
let cur = active.kind; let cur = active.kind;
if cur == current { if cur == current {
pending = None; // back to the current backend before debounce elapsed — no switch pending = None; // back to the current backend before debounce elapsed — no switch
@@ -2751,6 +2983,9 @@ struct SessionContext {
seconds: u32, seconds: u32,
/// Session stop flag (set on disconnect / reconnect-preempt). /// Session stop flag (set on disconnect / reconnect-preempt).
stop: Arc<AtomicBool>, 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. /// Accepted mid-stream mode switches — the pipeline is rebuilt at the new mode.
reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>, reconfig: std::sync::mpsc::Receiver<punktfunk_core::Mode>,
/// Client decode-recovery keyframe requests. /// Client decode-recovery keyframe requests.
@@ -2810,6 +3045,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
mode, mode,
seconds, seconds,
stop, stop,
quit,
reconfig, reconfig,
keyframe, keyframe,
compositor, compositor,
@@ -2859,8 +3095,8 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush) let _idd_setup_guard = (plan.capture == crate::session_plan::CaptureBackend::IddPush)
.then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone())); .then(|| crate::vdisplay::manager::vdm().begin_idd_setup(stop.clone()));
let (mut capturer, mut enc, mut frame, mut interval) = let (mut capturer, mut enc, mut frame, mut interval, mut cur_node_id) =
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). // Setup done — release the IDD-push setup lock so the next reconnect can begin (and preempt us).
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
drop(_idd_setup_guard); drop(_idd_setup_guard);
@@ -3006,8 +3242,11 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession { crate::vdisplay::apply_session_env(&crate::vdisplay::ActiveSession {
kind: sw.kind, kind: sw.kind,
env: sw.env, env: sw.env,
compositor_pid: None,
}); });
crate::vdisplay::apply_input_env(sw.compositor); // A mid-stream Game↔Desktop switch is not a fresh dedicated launch — route input at the
// switched-to backend's normal sub-mode.
crate::vdisplay::apply_input_env(sw.compositor, false);
// Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still // Switching INTO a desktop mid-stream: the xdg portal / systemd-user env may still
// point at the old session, so input would silently not land until a reconnect. // point at the old session, so input would silently not land until a reconnect.
// Settle it (env push + KWin portal restart) before the injector reopens against it. // Settle it (env push + KWin portal restart) before the injector reopens against it.
@@ -3028,17 +3267,19 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bitrate_kbps, bitrate_kbps,
bit_depth, bit_depth,
plan, plan,
&quit,
)?; )?;
Ok((new_vd, pipe)) Ok((new_vd, pipe))
})(); })();
match rebuilt { match rebuilt {
Ok((new_vd, (new_cap, new_enc, new_frame, new_interval))) => { Ok((new_vd, (new_cap, new_enc, new_frame, new_interval, new_node_id))) => {
// Replace the pipeline first (drops the old capturer → old PipeWire stream + // Replace the pipeline first (drops the old capturer → old PipeWire stream +
// virtual output), then the factory (drops e.g. the old KWin connection). // virtual output), then the factory (drops e.g. the old KWin connection).
capturer = new_cap; capturer = new_cap;
enc = new_enc; enc = new_enc;
frame = new_frame; frame = new_frame;
interval = new_interval; interval = new_interval;
cur_node_id = new_node_id;
vd = new_vd; vd = new_vd;
compositor = sw.compositor; compositor = sw.compositor;
next = std::time::Instant::now(); next = std::time::Instant::now();
@@ -3071,9 +3312,9 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// Build the new pipeline BEFORE dropping the old one: the host already acked // 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 // the switch as accepted, so a rebuild failure must not kill an otherwise
// healthy session — keep streaming the current mode and log instead. // 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) => { Ok(next_pipe) => {
(capturer, enc, frame, interval) = next_pipe; (capturer, enc, frame, interval, cur_node_id) = next_pipe;
cur_mode = new_mode; cur_mode = new_mode;
next = std::time::Instant::now(); next = std::time::Instant::now();
} }
@@ -3140,6 +3381,27 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// bounded retry is exhausted; the consecutive cap stops a flapping source from looping the // bounded retry is exhausted; the consecutive cap stops a flapping source from looping the
// client through endless cold IDRs. // client through endless cold IDRs.
Err(e) => { Err(e) => {
// B2: a DEDICATED gamescope game session whose gamescope node is gone = the game
// exited (gamescope is a single-app compositor — it dies with its app). End the session
// CLEANLY — close with `APP_EXITED_CLOSE_CODE` so a launcher client returns to its
// library instead of surfacing a failure — rather than the capture-loss rebuild + 40 s
// timeout. Gated to the dedicated bare-spawn launch (`launch_is_nested`), so a normal
// Bazzite/desktop capture loss still rebuilds in place.
#[cfg(target_os = "linux")]
if launch.is_some()
&& crate::vdisplay::launch_is_nested(compositor)
&& crate::vdisplay::dedicated_game_exited(cur_node_id)
{
tracing::info!(
"dedicated game session: the game exited — ending the session cleanly"
);
quit.store(true, Ordering::SeqCst); // skip keep-alive linger — the game is gone
conn.close(
punktfunk_core::quic::APP_EXITED_CLOSE_CODE.into(),
b"game exited",
);
break;
}
capture_rebuilds += 1; capture_rebuilds += 1;
if capture_rebuilds > MAX_CAPTURE_REBUILDS { if capture_rebuilds > MAX_CAPTURE_REBUILDS {
return Err(e).context("capture lost — rebuild attempts exhausted"); return Err(e).context("capture lost — rebuild attempts exhausted");
@@ -3157,14 +3419,18 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
// appears — no reconnect. // appears — no reconnect.
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40); const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET; let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET;
let (new_cap, new_enc, new_frame, new_interval) = loop { let (new_cap, new_enc, new_frame, new_interval, new_node_id) = loop {
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids // Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
// retargeting (then we stick to the pinned backend and just rebuild it). // retargeting (then we stick to the pinned backend and just rebuild it).
if crate::config::config().compositor.is_none() { if crate::config::config().compositor.is_none() {
let active = crate::vdisplay::detect_active_session(); let active = crate::vdisplay::detect_active_session();
// A4: fold any compositor-instance change into the epoch/invalidation before we
// rebuild, so the rebuild's acquire won't reuse a dead-instance node.
crate::vdisplay::observe_session_instance(&active);
if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) { if let Some(c) = crate::vdisplay::compositor_for_kind(active.kind) {
crate::vdisplay::apply_session_env(&active); crate::vdisplay::apply_session_env(&active);
crate::vdisplay::apply_input_env(c); // Capture-loss rebuild follows the live box session, not a fresh dedicated launch.
crate::vdisplay::apply_input_env(c, false);
if c != compositor { if c != compositor {
if matches!( if matches!(
c, c,
@@ -3192,6 +3458,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
bitrate_kbps, bitrate_kbps,
bit_depth, bit_depth,
plan, plan,
&quit,
) { ) {
Ok(p) => break p, Ok(p) => break p,
Err(e2) => { Err(e2) => {
@@ -3210,6 +3477,7 @@ fn virtual_stream(ctx: SessionContext) -> Result<()> {
enc = new_enc; enc = new_enc;
frame = new_frame; frame = new_frame;
interval = new_interval; interval = new_interval;
cur_node_id = new_node_id;
enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway enc.request_keyframe(); // belt-and-suspenders; a fresh encoder opens on an IDR anyway
next = std::time::Instant::now(); next = std::time::Instant::now();
tracing::info!( tracing::info!(
@@ -3400,6 +3668,10 @@ type Pipeline = (
Box<dyn crate::encode::Encoder>, Box<dyn crate::encode::Encoder>,
crate::capture::CapturedFrame, crate::capture::CapturedFrame,
std::time::Duration, std::time::Duration,
// The virtual output's PipeWire node id — used by the B2 dedicated game-exit probe to check THIS
// session's own node (scoped), not any gamescope node. `0` for backends without a PipeWire node
// (Windows IDD-push), which never take the dedicated-gamescope B2 path anyway.
u32,
); );
/// Build the pipeline, retrying *transient* failures with bounded exponential backoff. /// Build the pipeline, retrying *transient* failures with bounded exponential backoff.
@@ -3418,6 +3690,7 @@ fn build_pipeline_with_retry(
bitrate_kbps: u32, bitrate_kbps: u32,
bit_depth: u8, bit_depth: u8,
plan: crate::session_plan::SessionPlan, plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
) -> Result<Pipeline> { ) -> Result<Pipeline> {
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed // ~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 // gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
@@ -3444,7 +3717,7 @@ fn build_pipeline_with_retry(
const MAX_ATTEMPTS: u32 = 8; const MAX_ATTEMPTS: u32 = 8;
let mut backoff = std::time::Duration::from_millis(500); let mut backoff = std::time::Duration::from_millis(500);
for attempt in 1..=MAX_ATTEMPTS { 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) => { Ok(pipe) => {
if attempt > 1 { if attempt > 1 {
tracing::info!(attempt, "pipeline up after retry"); tracing::info!(attempt, "pipeline up after retry");
@@ -3507,8 +3780,23 @@ fn build_pipeline(
bitrate_kbps: u32, bitrate_kbps: u32,
bit_depth: u8, bit_depth: u8,
plan: crate::session_plan::SessionPlan, plan: crate::session_plan::SessionPlan,
quit: &Arc<AtomicBool>,
) -> Result<Pipeline> { ) -> 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")?;
// A2: if this was a REUSED kept display and its first frame fails, tear the (dead) pool entry down
// so the retry loop's next acquire creates fresh instead of re-wedging on the same corpse. Read the
// gen BEFORE `capture_virtual_output` consumes `vout`. (Linux-only — the pool is Linux.)
#[cfg(target_os = "linux")]
let reused_gen = vout.reused_gen;
// The virtual output's PipeWire node id — kept for the B2 dedicated game-exit probe (scoped to
// this session's own node). Read before `capture_virtual_output` consumes `vout`.
let node_id = vout.node_id;
// The backend reports the refresh it actually achieved in `preferred_mode.2` (KWin may cap a // 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 // 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 // clock to that, not the requested rate, so we don't emit phantom duplicate frames over a
@@ -3533,7 +3821,17 @@ fn build_pipeline(
crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture) crate::capture::capture_virtual_output(vout, plan.output_format(), plan.capture)
.context("capture virtual output")?; .context("capture virtual output")?;
capturer.set_active(true); capturer.set_active(true);
let frame = capturer.next_frame().context("first frame")?; let frame = match capturer.next_frame().context("first frame") {
Ok(f) => f,
Err(e) => {
// A reused kept display was dead — invalidate it so the next attempt creates fresh (A2).
#[cfg(target_os = "linux")]
if let Some(g) = reused_gen {
crate::vdisplay::registry::mark_failed(g);
}
return Err(e);
}
};
// `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client // `bit_depth` is the handshake-negotiated value (8, or 10 = HEVC Main10 when the client
// advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome. // advertised VIDEO_CAP_10BIT and the host opted in). Threaded down from the Welcome.
let enc = crate::encode::open_video( let enc = crate::encode::open_video(
@@ -3560,7 +3858,7 @@ fn build_pipeline(
); );
} }
let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64); let interval = std::time::Duration::from_secs_f64(1.0 / effective_hz.max(1) as f64);
Ok((capturer, enc, frame, interval)) Ok((capturer, enc, frame, interval, node_id))
} }
#[cfg(test)] #[cfg(test)]
@@ -3581,6 +3879,43 @@ mod tests {
assert!(adapt_fec(u32::MAX) <= FEC_MAX); 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] #[test]
fn compositor_resolution_precedence() { fn compositor_resolution_precedence() {
use crate::vdisplay::Compositor::*; use crate::vdisplay::Compositor::*;
@@ -3756,10 +4091,18 @@ mod tests {
/// End-to-end through the C ABI — the exact contract platform clients (Swift) link: /// End-to-end through the C ABI — the exact contract platform clients (Swift) link:
/// in-process punktfunk/1 host, `punktfunk_connect` (TOFU → pinned reconnect) → /// in-process punktfunk/1 host, `punktfunk_connect` (TOFU → pinned reconnect) →
/// `punktfunk_connection_next_au` pulls verified frames → `punktfunk_connection_send_input` /// `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 /// enqueues → `punktfunk_connection_close`. Three sequential sessions against ONE host
/// process prove the persistent listener, and a wrong pin is rejected. /// process prove the persistent listener, and a wrong pin is rejected.
#[test] #[test]
fn c_abi_connection_roundtrip() { fn c_abi_connection_roundtrip() {
let _serial = SESSION_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
use punktfunk_core::abi::{ use punktfunk_core::abi::{
punktfunk_connect, punktfunk_connection_close, punktfunk_connection_mode, punktfunk_connect, punktfunk_connection_close, punktfunk_connection_mode,
punktfunk_connection_send_input, punktfunk_connection_send_input,
@@ -3778,6 +4121,8 @@ mod tests {
allow_pairing: false, allow_pairing: false,
pairing_pin: None, pairing_pin: None,
paired_store: None, paired_store: None,
data_port: None,
idle_timeout: None,
}) })
}); });
std::thread::sleep(std::time::Duration::from_millis(500)); std::thread::sleep(std::time::Duration::from_millis(500));
@@ -3946,6 +4291,7 @@ mod tests {
/// admitted to a session with no PIN and no reconnect. /// admitted to a session with no PIN and no reconnect.
#[test] #[test]
fn delegated_approval_admits_after_knock() { 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::client::NativeClient;
use punktfunk_core::quic::endpoint; use punktfunk_core::quic::endpoint;
@@ -3972,6 +4318,8 @@ mod tests {
allow_pairing: false, allow_pairing: false,
pairing_pin: None, pairing_pin: None,
paired_store: None, // unused: the shared `np` IS the store handle 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 0, // no mgmt API in this test → advertise no `mgmt` mDNS port
np_host, np_host,
@@ -4055,6 +4403,7 @@ mod tests {
/// identity gets a session on a pairing-required host; an anonymous client does not. /// identity gets a session on a pairing-required host; an anonymous client does not.
#[test] #[test]
fn pairing_ceremony_and_gate() { 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::client::NativeClient;
use punktfunk_core::quic::endpoint; use punktfunk_core::quic::endpoint;
@@ -4070,6 +4419,8 @@ mod tests {
allow_pairing: false, allow_pairing: false,
pairing_pin: Some("4321".into()), pairing_pin: Some("4321".into()),
paired_store: Some(test_paired_path()), paired_store: Some(test_paired_path()),
data_port: None,
idle_timeout: None,
}) })
}); });
std::thread::sleep(std::time::Duration::from_millis(500)); std::thread::sleep(std::time::Duration::from_millis(500));
+389 -28
View File
@@ -21,6 +21,29 @@ pub use punktfunk_core::Mode;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use std::os::fd::OwnedFd; use std::os::fd::OwnedFd;
/// Who owns a [`VirtualOutput`]'s lifecycle — the honest declaration that lets the registry
/// (`design/gamemode-and-dedicated-sessions.md` Part A1) pool **only what it owns** instead of
/// keeping outputs whose real lifecycle lives elsewhere (the gamescope managed/attach paths, which
/// are governed by the gamescope module's own session machinery). Extends the CLAUDE.md invariant
/// "the registry owns display lifecycle" with its converse: what the registry does not own, it must
/// not pretend to keep.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum DisplayOwnership {
/// The registry owns the lifecycle: it may pool, linger, pin, and tear this display down (KWin,
/// Mutter, wlroots, gamescope **bare spawn**, and the Windows manager-delegated monitor). The
/// default — a backend that says nothing is registry-owned.
#[default]
Owned,
/// Someone else's display, merely mirrored: no keep-alive, no topology, no reuse (gamescope
/// **attach** to a foreign session). Codifies the design-doc §7 "attach = unmanaged pass-through"
/// row.
External,
/// A box-level session the gamescope module manages (the managed `gamescope-session-plus` /
/// SteamOS takeover). Passed through by the registry (its restore lifecycle is the gamescope
/// module's until Part A3 hands the registry a real keepalive + restore duty).
SessionManaged,
}
/// A created virtual output: a PipeWire source to capture, plus an owned keepalive whose drop /// A created virtual output: a PipeWire source to capture, plus an owned keepalive whose drop
/// tears the output down (releases the compositor-side resource). /// tears the output down (releases the compositor-side resource).
/// ///
@@ -44,6 +67,41 @@ pub struct VirtualOutput {
pub win_capture: Option<crate::capture::dxgi::WinCaptureTarget>, pub win_capture: Option<crate::capture::dxgi::WinCaptureTarget>,
/// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown. /// Keeps the output — and whatever connection/thread backs it — alive; dropped on teardown.
pub keepalive: Box<dyn Send>, pub keepalive: Box<dyn Send>,
/// Who owns this display's lifecycle (`design/gamemode-and-dedicated-sessions.md` A1). The
/// registry pools/keep-alives only [`DisplayOwnership::Owned`] outputs; `External`/`SessionManaged`
/// pass through (the capturer holds the keepalive, teardown on drop). Defaults to `Owned`.
pub ownership: DisplayOwnership,
/// `Some(gen)` when [`registry::acquire`](crate::vdisplay::registry::acquire) handed this back as a
/// **reused** kept display (`design/gamemode-and-dedicated-sessions.md` A2), so the pipeline builder
/// can [`registry::mark_failed(gen)`](crate::vdisplay::registry::mark_failed) if the first frame
/// fails on it — tearing the corpse down so the retry loop's next acquire creates fresh instead of
/// re-wedging on the same dead node. `None` on a fresh create / non-poolable output. Linux-only (the
/// keep-alive pool is Linux).
#[cfg(target_os = "linux")]
pub reused_gen: Option<u64>,
}
impl VirtualOutput {
/// A registry-[owned](DisplayOwnership::Owned) output — the common case (KWin/Mutter/wlroots,
/// gamescope bare-spawn, Windows). Fills `ownership: Owned`; the caller sets the platform fields.
pub fn owned(
node_id: u32,
preferred_mode: Option<(u32, u32, u32)>,
keepalive: Box<dyn Send>,
) -> VirtualOutput {
VirtualOutput {
node_id,
#[cfg(target_os = "linux")]
remote_fd: None,
preferred_mode,
#[cfg(target_os = "windows")]
win_capture: None,
keepalive,
ownership: DisplayOwnership::Owned,
#[cfg(target_os = "linux")]
reused_gen: None,
}
}
} }
/// Pluggable virtual-output creation, per compositor. /// Pluggable virtual-output creation, per compositor.
@@ -64,6 +122,147 @@ pub trait VirtualDisplay: Send {
/// Default: no-op — only the Windows pf-vdisplay backend uses it (Linux compositors own their virtual /// 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. /// output identity). `None` = anonymous/unpaired/GameStream → the backend's auto (slot-based) identity.
fn set_client_identity(&mut self, _fingerprint: Option<[u8; 32]>) {} 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) {}
/// Will a [`create`](Self::create) for the CURRENT request produce a registry-poolable
/// ([`DisplayOwnership::Owned`], keep-alive-able) display? The registry consults this **before**
/// its keep-alive reuse lookup, so it never hands a kept display of one flavor to a request of
/// another — specifically a gamescope managed/attach acquire must not reuse a kept **bare-spawn**
/// (they share the backend name `"gamescope"`). Default `true`; only gamescope overrides it,
/// returning `false` when the env selects attach/managed (consistent with the `ownership` its
/// `create` will report). See `design/gamemode-and-dedicated-sessions.md` A1.
fn poolable_now(&self) -> bool {
true
}
/// The resolved launch command carried on this backend instance (set via
/// [`set_launch_command`](Self::set_launch_command)). The registry reads it to key keep-alive reuse
/// on `(backend, mode, launch)` (`design/gamemode-and-dedicated-sessions.md` A2) — a kept display
/// running game A must never be handed to a session that asked to launch game B. Default `None`
/// (backends that never nest a command); only gamescope reports its `cmd`.
fn launch_command(&self) -> Option<String> {
None
}
/// Is the kept display's `node_id` still live, checked **before** the registry REUSES it on a
/// reconnect (`design/gamemode-and-dedicated-sessions.md` A2)? A `false` tells the registry to tear
/// the dead entry down and create fresh instead of handing back a corpse (which would then fail
/// capture and burn a retry). Default `true` (honest optimism — the [`mark_failed`] path is the
/// backstop for a display that dies between this check and first frame). Only gamescope overrides
/// it (its nested session dies when the game exits, independently of any compositor); KWin/Mutter
/// nodes die only with their compositor, which the session-epoch invalidation (A4) already reaps.
///
/// [`mark_failed`]: crate::vdisplay::registry::mark_failed
fn kept_display_alive(&mut self, _node_id: u32) -> bool {
true
}
}
/// The **session epoch** — bumped whenever session detection observes a different compositor
/// *instance*: an [`ActiveKind`] change, **or** a new compositor PID for the same kind (the
/// Desktop→Game→Desktop bounce that brings up a fresh KWin/gamescope with an unrelated node-id space).
/// Pooled displays stamp the epoch at creation; the registry only reuses an entry whose epoch still
/// matches, and its linger timer reaps entries from dead epochs — so a switch can never hand back a
/// node id that now means nothing (`design/gamemode-and-dedicated-sessions.md` A4).
static SESSION_EPOCH: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// The current [session epoch](SESSION_EPOCH). Read by the registry at acquire (to stamp new entries
/// and gate reuse) and by its linger timer (to reap dead-epoch zombies).
pub fn session_epoch() -> u64 {
SESSION_EPOCH.load(std::sync::atomic::Ordering::Relaxed)
}
/// Bump the [session epoch](SESSION_EPOCH) — call when session detection sees a new compositor
/// instance (kind change, or same-kind new PID). Returns the new value.
pub fn bump_session_epoch() -> u64 {
SESSION_EPOCH.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1
}
/// The last-observed compositor instance `(kind, pid)`, so [`observe_session_instance`] can tell a
/// genuine instance change from a stable re-detect.
static LAST_INSTANCE: std::sync::Mutex<Option<(ActiveKind, Option<u32>)>> =
std::sync::Mutex::new(None);
/// Observe the freshly-[detected](detect_active_session) live session and, if the compositor
/// *instance* changed since the last observation — a different [`ActiveKind`], **or** the same kind
/// with a new PID (a compositor restart / Desktop→Game→Desktop bounce) — bump the [session
/// epoch](SESSION_EPOCH) and [invalidate](registry::invalidate_backend) the previous backend's kept
/// displays, so a reconnect can never reuse a node id from the dead instance (A4). Idempotent per
/// instance; the first observation just records the baseline. Cheap on the steady state (one mutex
/// read); the registry lock is taken only on an actual change. Call from every site that detects the
/// session (the per-connect resolve, the mid-stream watcher, the capture-loss re-detect).
pub fn observe_session_instance(active: &ActiveSession) {
let cur = (active.kind, active.compositor_pid);
let mut last = LAST_INSTANCE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(prev) = *last {
// Only a **desktop** compositor (KWin / Mutter / wlroots) instance change bumps the epoch +
// invalidates its kept displays — its PipeWire node dies with the compositor. A **gamescope**
// session (`ActiveKind::Gaming`) is NOT the epoch's subject: the box's game-mode / managed
// gamescope isn't pooled, and dedicated **spawns** are independent nested sessions whose nodes
// outlive any active-session change. So a game-mode gamescope restart, a Gaming↔Gaming winning-PID
// flap (e.g. B1 stopping the autologin before a dedicated spawn), or a coexisting-gamescope set
// change must NOT bump/invalidate — that would tear down a live/kept dedicated session (review
// findings #6/#7/#10). Gate the whole action on a desktop kind being involved.
if prev != cur && (is_desktop_kind(prev.0) || is_desktop_kind(cur.0)) {
// Invalidate only the OLD backend, and only if it was a desktop compositor (never gamescope).
if is_desktop_kind(prev.0) {
if let Some(old) = compositor_for_kind(prev.0) {
registry::invalidate_backend(old.id());
}
}
let epoch = bump_session_epoch();
tracing::info!(
from = ?prev.0,
to = ?cur.0,
epoch,
"desktop compositor instance changed — session epoch bumped"
);
}
}
*last = Some(cur);
}
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
fn is_desktop_kind(kind: ActiveKind) -> bool {
matches!(
kind,
ActiveKind::DesktopKde | ActiveKind::DesktopGnome | ActiveKind::DesktopWlroots
)
} }
/// Compositors punktfunk knows how to drive (plan §6). /// Compositors punktfunk knows how to drive (plan §6).
@@ -204,6 +403,10 @@ pub struct SessionEnv {
pub struct ActiveSession { pub struct ActiveSession {
pub kind: ActiveKind, pub kind: ActiveKind,
pub env: SessionEnv, pub env: SessionEnv,
/// PID of the winning compositor process (`None` when nothing live). The session watcher compares
/// it across polls so a **same-kind** compositor restart (Desktop→Game→Desktop) bumps the session
/// epoch — a fresh instance's node-id space is unrelated to the old one's (A4).
pub compositor_pid: Option<u32>,
} }
impl ActiveSession { impl ActiveSession {
@@ -216,6 +419,7 @@ impl ActiveSession {
dbus_session_bus_address: default_bus(&default_runtime_dir()), dbus_session_bus_address: default_bus(&default_runtime_dir()),
..Default::default() ..Default::default()
}, },
compositor_pid: None,
} }
} }
} }
@@ -267,6 +471,9 @@ pub fn detect_active_session() -> ActiveSession {
// `pkill -x` discipline (exact, ≤15 chars so untruncated). // `pkill -x` discipline (exact, ≤15 chars so untruncated).
let mut kind = ActiveKind::None; let mut kind = ActiveKind::None;
let mut best = 0u8; let mut best = 0u8;
// The winning compositor's PID — kept so a same-kind compositor RESTART (a new PID) bumps the
// session epoch (A4), not just a kind change.
let mut winning_pid: Option<u32> = None;
if let Ok(entries) = std::fs::read_dir("/proc") { if let Ok(entries) = std::fs::read_dir("/proc") {
for e in entries.flatten() { for e in entries.flatten() {
let name = e.file_name(); let name = e.file_name();
@@ -291,9 +498,22 @@ pub fn detect_active_session() -> ActiveSession {
"sway" | "Hyprland" | "hyprland" | "river" => (ActiveKind::DesktopWlroots, 4), "sway" | "Hyprland" | "hyprland" | "river" => (ActiveKind::DesktopWlroots, 4),
_ => continue, _ => continue,
}; };
let pid = name.parse::<u32>().ok();
if prio > best { if prio > best {
best = prio; best = prio;
kind = k; kind = k;
winning_pid = pid;
} else if prio == best {
// Deterministic tie-break among same-top-priority processes: keep the LOWEST pid, so a
// duplicate same-kind compositor (two `kwin_wayland`) can't make `winning_pid` flap with
// `/proc` enumeration order — which `observe_session_instance` would misread as a
// compositor restart and tear a live display down (re-review low-severity note).
if let (Some(p), Some(w)) = (pid, winning_pid) {
if p < w {
kind = k;
winning_pid = Some(p);
}
}
} }
} }
} }
@@ -321,6 +541,7 @@ pub fn detect_active_session() -> ActiveSession {
dbus_session_bus_address: dbus, dbus_session_bus_address: dbus,
xdg_current_desktop, xdg_current_desktop,
}, },
compositor_pid: winning_pid,
} }
} }
@@ -403,21 +624,11 @@ pub fn apply_session_env(active: &ActiveSession) {
if active.kind == ActiveKind::DesktopGnome { if active.kind == ActiveKind::DesktopGnome {
std::env::set_var("PUNKTFUNK_FORCE_SHM", "1"); std::env::set_var("PUNKTFUNK_FORCE_SHM", "1");
} }
// Stream the desktop as the SOLE output: promote the per-session virtual output to PRIMARY so // Topology (Stage 2): the per-compositor backends (KWin/Mutter) now read
// the panels + windows land on the streamed surface, not an unstreamed real output (the // [`effective_topology`] directly at create time — the console policy, else the legacy
// auto-detected desktop path *is* "stream this desktop"). Default-on for the auto path; an // `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` env, else the Auto default (exclusive on the
// explicit `PUNKTFUNK_{KWIN,MUTTER}_VIRTUAL_PRIMARY` still wins. // auto-desktop path). So this connect-path no longer writes that env (one fewer process-env
match active.kind { // mutation on the `ENV_LOCK` surface); `effective_topology()` computes the identical result.
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");
}
_ => {}
}
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn apply_session_env(_active: &ActiveSession) {} pub fn apply_session_env(_active: &ActiveSession) {}
@@ -491,6 +702,7 @@ pub enum GamescopeMode {
/// default is a per-session bare spawn — the path that nests the client's launch command. /// default is a per-session bare spawn — the path that nests the client's launch command.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn pick_gamescope_mode( fn pick_gamescope_mode(
dedicated_launch: bool,
force_managed: bool, force_managed: bool,
attach_env: bool, attach_env: bool,
node_env: bool, node_env: bool,
@@ -502,6 +714,11 @@ fn pick_gamescope_mode(
GamescopeMode::Managed GamescopeMode::Managed
} else if attach_env || node_env { } else if attach_env || node_env {
GamescopeMode::Attach GamescopeMode::Attach
} else if dedicated_launch {
// A dedicated game session always spawns its own headless gamescope at the client's mode,
// nesting just the game — outranking managed-infra / foreign-attach, but not the explicit
// operator MANAGED/ATTACH/NODE overrides above (debug/CI). (design/gamemode-and-dedicated-sessions.md §5.3)
GamescopeMode::Spawn
} else if session_env || managed_infra { } else if session_env || managed_infra {
GamescopeMode::Managed GamescopeMode::Managed
} else if foreign_gamescope { } else if foreign_gamescope {
@@ -521,7 +738,7 @@ fn pick_gamescope_mode(
/// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED` /// nesting the session's launch command — the plain-distro default). `PUNKTFUNK_GAMESCOPE_MANAGED`
/// forces managed over all of it. /// forces managed over all of it.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub fn apply_input_env(chosen: Compositor) { pub fn apply_input_env(chosen: Compositor, dedicated_launch: bool) {
let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); let _env_guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let backend = match chosen { let backend = match chosen {
Compositor::Gamescope => "gamescope", Compositor::Gamescope => "gamescope",
@@ -535,6 +752,7 @@ pub fn apply_input_env(chosen: Compositor) {
std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend); std::env::set_var("PUNKTFUNK_INPUT_BACKEND", backend);
if chosen == Compositor::Gamescope { if chosen == Compositor::Gamescope {
let mode = pick_gamescope_mode( let mode = pick_gamescope_mode(
dedicated_launch,
std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(), std::env::var_os("PUNKTFUNK_GAMESCOPE_MANAGED").is_some(),
std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(), std::env::var_os("PUNKTFUNK_GAMESCOPE_ATTACH").is_some(),
std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(), std::env::var_os("PUNKTFUNK_GAMESCOPE_NODE").is_some(),
@@ -566,7 +784,34 @@ pub fn apply_input_env(chosen: Compositor) {
} }
} }
#[cfg(not(target_os = "linux"))] #[cfg(not(target_os = "linux"))]
pub fn apply_input_env(_chosen: Compositor) {} pub fn apply_input_env(_chosen: Compositor, _dedicated_launch: bool) {}
/// Should a game-launching session get a **dedicated** headless gamescope (`game_session=dedicated`
/// policy, `design/gamemode-and-dedicated-sessions.md` B0)? True only when the session carries a
/// launch, the policy selects `dedicated`, AND gamescope is actually available (else it degrades to
/// `auto` honestly). Computed at the handshake and threaded into [`apply_input_env`] /
/// [`resolve_compositor`] as a value (no new env knob — the `ENV_LOCK` discipline).
pub fn wants_dedicated_game_session(has_launch: bool) -> bool {
use policy::GameSession;
if !has_launch || policy::prefs().game_session() != GameSession::Dedicated {
return false;
}
#[cfg(target_os = "linux")]
{
if gamescope::is_available() {
true
} else {
tracing::info!(
"game_session=dedicated but gamescope is unavailable — falling back to auto routing"
);
false
}
}
#[cfg(not(target_os = "linux"))]
{
false // Windows: a launching session opens into the one desktop (no gamescope)
}
}
/// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare /// Will `vd.create` on this backend NEST the session's launch command itself (gamescope's bare
/// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command /// spawn runs it inside the new gamescope)? When true the session must NOT also spawn the command
@@ -589,6 +834,27 @@ pub fn launch_into_gamescope_session(cmd: &str) -> Result<std::process::Child> {
gamescope::launch_into_session(cmd) gamescope::launch_into_session(cmd)
} }
/// B2: has a **dedicated** gamescope game session's game exited (its `node_id` doesn't reappear within a
/// short window after capture loss)? The dedicated-spawn session ends cleanly on `true` instead of the
/// capture-loss rebuild. Scoped to the session's OWN node so a coexisting gamescope doesn't mask the
/// exit (review #4/#8). Always `false` off Linux.
#[cfg(target_os = "linux")]
pub fn dedicated_game_exited(node_id: u32) -> bool {
gamescope::game_session_exited(node_id)
}
#[cfg(not(target_os = "linux"))]
pub fn dedicated_game_exited(_node_id: u32) -> bool {
false
}
/// Cancel any pending TV-session restore because a client (re)connected (review #3). No-op off Linux.
#[cfg(target_os = "linux")]
pub fn cancel_pending_tv_restore() {
gamescope::cancel_pending_restore();
}
#[cfg(not(target_os = "linux"))]
pub fn cancel_pending_tv_restore() {}
/// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing /// Detect the compositor to drive: explicit `PUNKTFUNK_COMPOSITOR` override (legacy / CI / forcing
/// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box /// a backend for a test), else the **live session** ([`detect_active_session`] — so a Bazzite box
/// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read. /// follows Gaming↔Desktop switches), else a last-resort `XDG_CURRENT_DESKTOP` read.
@@ -723,14 +989,97 @@ pub fn start_restore_worker() -> std::sync::Arc<()> {
std::sync::Arc::new(()) std::sync::Arc::new(())
} }
/// Recover a stranded TV takeover from a crashed previous host instance
/// (`design/gamemode-and-dedicated-sessions.md` A3). Call once at `serve` startup, alongside
/// [`start_restore_worker`]. No-op when no takeover was persisted (a clean start).
#[cfg(target_os = "linux")]
pub fn restore_takeover_on_startup() {
gamescope::restore_takeover_on_startup();
}
#[cfg(not(target_os = "linux"))]
pub fn restore_takeover_on_startup() {}
// 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 // 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. // backends under `vdisplay/windows/`; `#[path]` keeps the `crate::vdisplay::*` module names flat.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/gamescope.rs"] #[path = "vdisplay/linux/gamescope.rs"]
mod gamescope; mod gamescope;
#[cfg(target_os = "windows")] // Platform-neutral per-client stable display-id map (Stage 3): Windows seeds the monitor EDID +
#[path = "vdisplay/windows/identity.rs"] // 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; 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")] #[cfg(target_os = "linux")]
#[path = "vdisplay/linux/kwin.rs"] #[path = "vdisplay/linux/kwin.rs"]
mod kwin; mod kwin;
@@ -778,21 +1127,33 @@ mod tests {
fn gamescope_mode_ladder() { fn gamescope_mode_ladder() {
use GamescopeMode::*; use GamescopeMode::*;
let pick = pick_gamescope_mode; let pick = pick_gamescope_mode;
// (force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope) // (dedicated_launch, force_managed, attach_env, node_env, session_env, managed_infra, foreign_gamescope)
// Plain distro, nothing running: bare spawn — the path that nests the launch command. // Plain distro, nothing running: bare spawn — the path that nests the launch command.
assert_eq!(pick(false, false, false, false, false, false), Spawn); assert_eq!(pick(false, false, false, false, false, false, false), Spawn);
// Bazzite/SteamOS (session infra present): managed, as validated live. // Bazzite/SteamOS (session infra present): managed, as validated live.
assert_eq!(pick(false, false, false, false, true, false), Managed); assert_eq!(
assert_eq!(pick(false, false, false, false, true, true), Managed); pick(false, false, false, false, false, true, false),
Managed
);
assert_eq!(pick(false, false, false, false, false, true, true), Managed);
// Foreign gamescope on an infra-less box: attach and mirror it. // Foreign gamescope on an infra-less box: attach and mirror it.
assert_eq!(pick(false, false, false, false, false, true), Attach); assert_eq!(pick(false, false, false, false, false, false, true), Attach);
// Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra. // Operator-set PUNKTFUNK_GAMESCOPE_SESSION keeps managed even without detected infra.
assert_eq!(pick(false, false, false, true, false, false), Managed); assert_eq!(
pick(false, false, false, false, true, false, false),
Managed
);
// Explicit attach/node wins over infra… // Explicit attach/node wins over infra…
assert_eq!(pick(false, true, false, false, true, false), Attach); assert_eq!(pick(false, false, true, false, false, true, false), Attach);
assert_eq!(pick(false, false, true, true, true, false), Attach); assert_eq!(pick(false, false, false, true, true, true, false), Attach);
// …and force-managed wins over everything. // …and force-managed wins over everything.
assert_eq!(pick(true, true, true, false, false, false), Managed); assert_eq!(pick(false, true, true, true, false, false, false), Managed);
// A dedicated launch forces Spawn, outranking managed-infra + foreign-attach…
assert_eq!(pick(true, false, false, false, false, true, true), Spawn);
// …but the explicit operator overrides still win over dedicated.
assert_eq!(pick(true, true, false, false, false, true, false), Managed);
assert_eq!(pick(true, false, true, false, false, false, false), Attach);
assert_eq!(pick(true, false, false, true, false, false, false), Attach);
} }
#[test] #[test]
@@ -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"
);
}
}
}

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