Compare commits

...
89 Commits
Author SHA1 Message Date
enricobuehlerandClaude Fable 5 5807d3a8a6 fix(tools): the loss harness catches up with seal_streamed_chunk's slice flag
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
ci / rust-arm64 (push) Successful in 2m24s
49bc93cd grew seal_streamed_chunk a slice_end parameter and updated every
caller except this one — the Linux clippy leg has been red since (run
10615+). false = the legacy full-FEC-block granularity the harness always
exercised; its loopback client never advertises the P2 slice wire.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 18:16:40 +02:00
enricobuehlerandClaude Fable 5 2a6bf3c421 fix(vdisplay/gamescope): the takeover stops a live session's DM on every flavor — the mask never stopped SDDM's relogin
android / android (push) In progress
apple / screenshots (push) Blocked by required conditions
arch / build-publish (push) In progress
apple / swift (push) In progress
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Waiting to run
docker / deploy-docs (push) Blocked by required conditions
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Waiting to run
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Waiting to run
windows-host / canary-manifest (push) Blocked by required conditions
windows-host / winget-source (push) Blocked by required conditions
ci / rust (push) In progress
windows-host / package (push) In progress
ci / web (push) Successful in 1m7s
deb / build-publish (push) In progress
deb / build-publish-host (push) In progress
ci / docs-site (push) Successful in 2m12s
deb / build-publish-client-arm64 (push) In progress
ci / rust-arm64 (push) Successful in 2m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) In progress
docker / builders-arm64cross (push) Waiting to run
The managed takeover masked gamescope-session-plus@*.service and trusted
the mask to hold SDDM's Relogin=true loop at bay. On images whose sddm
session helper execs the session script directly (/etc/sddm/wayland-session
gamescope-session-plus steam — f43 bazzite-deck), the relogin never touches
the unit: SDDM relogged in ~3x/s for the whole stream, each attempt a full
bash --login session start failing against the managed instance. Live on
the .41 VM (2026-07-31): 328 forks/s, load 6+, 1481 logind sessions in
8 minutes, the journal flooded past its own rotation — and a 5070 Ti that
"could not hold 240fps".

A DM that drove a LIVE gaming session is now stopped for the stream's
duration on every flavor (the fragile-flavor machinery — lingering guard,
pkexec dm-helper, restore via reset-failed+restart, crash-persisted state —
already existed and is reused as-is; a pure dm_plan() derives the decision
and is unit-tested). The flavors now differ only in the degraded mode when
the stop is impossible: SDDM falls back to mask-only (the storm tax, but
Steam stays protected — never attach), fragile DMs still fail the takeover
into the attach degrade. The mask stays as belt-and-braces for the window
before the stop and for images that do route the relogin through the unit.

Verified: scripts/xcheck.sh linux clippy (-D warnings, --all-targets)
clean; pf-vdisplay suite 125/125 on Ubuntu 26.04.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:59:00 +02:00
enricobuehlerandClaude Fable 5 957cf7781c feat(apple): a Pencil near the glass pins the panel at the link ceiling
ci / web (push) Successful in 2m4s
ci / docs-site (push) Successful in 2m19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
ci / rust-arm64 (push) Successful in 3m42s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m26s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m16s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m11s
ci / rust (push) Failing after 2m7s
apple / swift (push) Successful in 4m59s
deb / build-publish (push) Successful in 6m51s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
deb / build-publish-host (push) Successful in 7m10s
docker / builders-arm64cross (push) Successful in 8s
android / android (push) Successful in 8m44s
arch / build-publish (push) Successful in 8m35s
docker / deploy-docs (push) Successful in 33s
flatpak / build-publish (push) Successful in 8m0s
windows-host / package (push) Successful in 18m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m27s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m29s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m5s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m1s
release / apple (push) Successful in 25m56s
apple / screenshots (push) Canceled after 4s
UIKit delivers Pencil events at the PANEL's cadence, and the deadline
link's rate hint pins the panel to the stream rate (min = preferred =
stream Hz, deliberately — the anti-idle-stall floor). Net effect: a 60 fps
stream on a 120 Hz iPad silently halved pencil sampling, 8.3 → 16.7 ms per
event batch — exactly the workload (drawing) that feels it most.

FrameRateHint gains a boost that lifts min/preferred to the range ceiling
while a Pencil is in range (hover or contact): PencilStream surfaces
proximity transitions from its emit() choke point, the view controller
debounces release by 2 s (edge-of-canvas hover flicker must not thrash the
link), and Stage2Pipeline/SessionPresenter stage it like the rate hint —
applied from the link's own thread, no-op under arrival/glass pacing.
Presents still pace at stream rate (extra updates vend into the
newest-wins stash), so the cost is empty link wakes scoped to proximity.
pf.present logs engage/release for on-glass verification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:46 +02:00
enricobuehlerandClaude Fable 5 7cf71dd218 feat(clients): the Apple deadline presenter reports its latch phase
Phase-locked capture had no client half on Apple — the host's v3 controller
(grid-locked submits, coherence-gated engage) shipped with only the Android
reporter feeding it. Now the stage-4 link thread flushes the same v2
circular arrival-phase statistic at ~1 Hz:

- PhaseReporter (Stage2Pipeline): the decode callback deposits per-AU
  reassembly-completion stamps, the CAMetalDisplayLink update deposits the
  latch grid (period = window-min of update spacing), and the flush ports
  punktfunk_core::phase::circular_latch verbatim — a period-smeared Wi-Fi
  link reads coherence ≈ 0 and the host correctly never engages; a wired
  link opens the gate. Binds/unbinds per session like DecodeReport.
- PunktfunkConnection.reportPhase wraps the existing ABI entry point.
- Hello honesty: iOS/tvOS advertise CLIENT_CAP_PHASE_LOCK (macOS stays
  without — the stage-2 arrival presenter has no latch grid), Android now
  sets the bit its reporter already earned, and the ABI grows the
  PUNKTFUNK_CLIENT_CAP_PHASE_LOCK mirror const (header regenerated).
  Advisory in v1: the host arms on report receipt.
- Stage-3 doc comment no longer calls itself the tvOS default (stage-4 took
  that over in the 2026-07 rebuild).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:46 +02:00
enricobuehlerandClaude Fable 5 86d4b36bfa chore(core): the Apple recv path documents its unsafe blocks
clippy -D warnings only lints this cfg(target_vendor = "apple") file on a
Mac, which CI never does — the three recv/recvmsg_x blocks predate the
undocumented_unsafe_blocks sweep and kept every local Apple-side clippy run
red.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:15 +02:00
enricobuehlerandClaude Fable 5 c002ca8746 fix(clients): pen batches split at the wire cap and the heartbeat survives tracking mode
Three drawing-path defects on the pen clients:

- The Apple in-range heartbeat was re-armed on every emit (~120 run-loop
  mutations/s during a stroke) and lived in .default run-loop mode only — a
  tracking-mode excursion >200 ms with a stationary pen crossed the host's
  force-release failsafe and dropped the held stroke. Now one long-lived
  50 ms timer in .common mode, resending only after ≥50 ms of send silence.
- Both clients TRUNCATED an over-8-sample coalesced run instead of splitting
  it into consecutive batches (the send_pen contract): Apple suffix(8)
  dropped the oldest samples, the Android JNI clamped count. An over-cap run
  means the UI thread hitched — exactly when dropping stroke geometry hurts
  most. Apple splits in emit(); the Android JNI loops send_pen over ≤8-sample
  chunks (Kotlin's per-emit ceiling is now 64 = a >250 ms stall).
- The iOS letterbox mapper did a lock+FFI currentMode() read per coalesced
  sample on the main thread, contending the ABI lock the batch send takes
  next — now a 250 ms TTL cache (mid-stream requestMode still tracked).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:41:15 +02:00
enricobuehlerandClaude Opus 5 d383161723 docs: the docs catch up with five releases of shipped work
ci / rust (push) Failing after 2m31s
ci / docs-site (push) Successful in 1m22s
ci / web (push) Successful in 1m48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m1s
ci / rust-arm64 (push) Successful in 2m2s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / builders-arm64cross (push) Successful in 20s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 36s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 36s
docker / deploy-docs (push) Canceled after 0s
~1150 feat/fix commits landed since v0.19 and the docs drifted badly. This is a
full sweep of every page against the code as shipped: ~280 verified corrections,
nine new pages, and one deletion.

The worst of what was wrong: the quickstart's five-minute path could not work
(`serve` never started the web console, so step 3 had no PIN to read); every
packaged Linux host runs `serve --gamestream` while security.md told readers to
leave GameStream off; HDR was documented as Windows-only; `PUNKTFUNK_SECURE_DDA`
was documented as a working knob that nothing reads; `PUNKTFUNK_INPUT_BACKEND`
listed a `uinput` value that does not exist and named libei for KDE instead of
kwin; README linked three pages deleted on 2026-07-05; and the rpm-ostree update
command pointed at a script no package installs.

Completeness: about half of what shipped since v0.19 had no page at all. New:
support-matrix (what works where, from 217 verified capability cells), input
(mouse/touch/pen — and the in-stream chords, so the docs finally say how to get
your mouse back), client-settings, profiles-and-links, game-library, clipboard,
wake-on-lan, hdr, uninstall. Updating existed but had zero inbound links.

status.md is gone: its facts moved into the support matrix, its shell stays as a
redirect so the public URL does not 404. roadmap.md is themes now, not a feature
checklist — checkboxes are what rotted.

Debian is no longer claimed. The .deb's Depends resolve against Ubuntu images,
nothing in CI builds or tests Debian, and Debian 12 is below the glibc 2.39
floor. The `debian` in the repo URL is the package format.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:37:06 +02:00
enricobuehlerandClaude Fable 5 49bc93cd20 feat(encode/windows): the sync NVENC session reads slices out mid-encode
ci / web (push) Successful in 1m2s
apple / swift (push) Successful in 1m19s
ci / rust-arm64 (push) Successful in 1m45s
ci / docs-site (push) Successful in 2m6s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
ci / rust (push) Canceled after 2m36s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Successful in 2m8s
android / android (push) Successful in 5m37s
deb / build-publish-host (push) Successful in 4m40s
deb / build-publish (push) Successful in 5m56s
apple / screenshots (push) Canceled after 6m40s
arch / build-publish (push) Canceled after 8m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 6m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 6m11s
windows-host / package (push) Canceled after 8m58s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
The Windows direct-NVENC session gets the Linux backend's sub-frame
chunked poll: a doNotWait sampling lock cuts each completed slice out of
the bitstream while the frame is still encoding, so the send loop's
streamed slice wire starts shipping an AU's front before its tail
exists. The default retrieve mode on Windows is synchronous, which is
exactly the mode the sampler requires - the async opt-in keeps its
whole-AU path and the chunked latch drops away when it is active.

The session's slice count now honors the negotiation instead of pinning
one: the direct-NVENC default of 4 clamped by the client's ceiling, so a
single-slice client (Amlogic-class decoders that never advertised
multi-slice) keeps today's wire shape and a multi-slice client gets real
boundaries to cut at. The pending queue carries a submit-time IDR hint
because an AU's early chunks ship before the driver reports the picture
type; sub-frame write itself stays env-gated (PUNKTFUNK_NVENC_SUBFRAME=1)
until the Windows on-glass A/B validates it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:34:31 +02:00
enricobuehlerandClaude Fable 5 780791ed81 feat(android): the presenter's latch margin starts at zero and earns its width
The 2.5 ms submit margin ahead of expected present assumed
SurfaceFlinger latches well before the vsync. On-glass (NP3, 120 Hz)
that lead is not needed: with the margin at zero every release still
made its vsync (paced=0 across full sessions) - meaning the constant was
2.5 ms of pure display latency on every frame, and it was quantizing
away exactly the readiness gains the slice-overlap pipeline just bought.
e2e on the Linux-host pairing dropped from 22-24 ms to 14-18 ms.

The margin is now adaptive instead of assumed: it starts at 0 and only
widens (+500 us per 1 Hz window, capped at the old 2.5 ms) when the
paced counter shows real latch misses - one-way per stream, so a margin
that proved necessary is never re-gambled mid-session. A device that
needs lead converges within seconds; a device that doesn't keeps the
full win. debug.punktfunk.latch_margin_us (0..=8000) pins the margin
for rebuild-free sweeps, and the presenter logs the resolved mode at
stream start.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:34:31 +02:00
enricobuehlerandClaude Fable 5 4240b76182 feat(android): slices feed the decoder as they arrive, not when the AU closes
The decode loop used to hold every access unit until its last wire
packet landed. With slice-progressive delivery (frame_parts) each newly
contiguous piece now goes straight into MediaCodec under
BUFFER_FLAG_PARTIAL_FRAME, the closing piece drops the flag, and the
decoder chews the front of the frame while its tail is still on the
wire.

Feeding partial input means owning its failure modes without a codec
flush: a broken sequence (dropped piece, orphan, oversize) closes the
dead AU with an empty non-partial buffer at its own pts, arms the
re-anchor freeze so the concealed output never reaches glass, and
requests a recovery keyframe - the same machinery ordinary loss already
rides. Per-AU accounting keeps its units: the RFI gap detector notes an
AU once, and the HUD stamps, host/network split and the phase-lock
arrival sensor ride only the completing delivery.

The opt-in is decoder truth and loop truth: every decoder this device
would use must pass FEATURE_PartialFrame, and only the async loop may
see parts - the legacy sync loop feeds whole AUs and stays that way.
Smoke-checked on-glass (NP3 vs the Windows host): byte-identical
degenerate path, presenter profile unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:34:31 +02:00
enricobuehlerandClaude Opus 5 de8430097e chore(clients): the bundled licence notices catch up with the root file
ci / web (push) Successful in 1m22s
ci / rust-arm64 (push) Successful in 1m31s
decky / build-publish (push) Failing after 7s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 9s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
ci / docs-site (push) Successful in 1m17s
ci / rust (push) Failing after 2m14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m2s
apple / swift (push) Successful in 3m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m28s
deb / build-publish-client-arm64 (push) Successful in 4m4s
docker / builders-arm64cross (push) Successful in 8s
deb / build-publish-host (push) Successful in 5m29s
deb / build-publish (push) Successful in 4m2s
android / android (push) Successful in 7m13s
docker / deploy-docs (push) Successful in 39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m31s
arch / build-publish (push) Successful in 9m20s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m54s
flatpak / build-publish (push) Successful in 6m29s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m59s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m52s
apple / screenshots (push) Successful in 20m57s
windows-host / package (push) Successful in 14m16s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
release / apple (push) Successful in 25m29s
The Apple and Android apps carry their own copy of THIRD-PARTY-NOTICES.txt
and show it on the acknowledgements screen. `7fc2775f` refreshed the root
file but not the copies — running the python generator directly skips the
sync `scripts/gen-third-party-notices.sh` does — so they had drifted to a
531-crate snapshot that predates the whole vendored-source section.

That section is where the OS marks' attribution lives, so both apps have
been shipping Font Awesome's CC BY 4.0 icons with no attribution visible
at all. Re-synced, which also carries the Bazzite Apache-2.0 notice the
previous commit adds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:17:02 +02:00
enricobuehlerandClaude Opus 5 5520167958 feat(clients): the OS marks tell gaming distros apart, and Windows looks current
Three things were wrong with the host-card OS icons.

The Windows mark was Font Awesome 5's, which is still the Windows 8/10 flag
with the perspective skew — dated next to the flat four-pane mark Microsoft
has shipped since Windows 11. No icon set has the current one (Simple Icons
carries no windows/microsoft slug at all), so it is drawn here: four equal
squares at the authentic 11.377 + 1.246 proportion. The Decky plugin was
pulling FaWindows straight from react-icons, so it now inlines the masters
like the web console does, or it would have kept the old flag regardless.

Bazzite, CachyOS and Nobara collapsed onto their family's mark. The host
already advertises the full chain, so this is purely missing art: all three
now ship a leaf mark, because "a Bazzite box" and "a Fedora box" are
different machines to the person reading the card. CachyOS and Nobara come
from Simple Icons; Bazzite has no icon anywhere, so its "b" is lifted out of
the project's own badge (Apache-2.0, attributed).

On Android every non-square mark was stretched. A VectorPainter maps the
viewport onto the ImageVector's default size with independent x and y
scales, so declaring a 448x512 Tux as 24x24 dp squashed it — silently, no
crash, no warning. The longest viewport edge now sets the 24 dp box and the
other follows the ratio, which is what Icon()'s ContentScale.Fit expects.
A unit test pins the invariant; every other client was already correct.

Also: scripts/gen-os-icons.sh replaces the undocumented hand-run pipeline
that turns a master into the GTK symbolic SVG, the Windows PNG and the Apple
template PDF. It reproduces the committed artifacts byte-identically.

Verified: web and Decky typecheck, Decky bundles, Android compiles and its
tests pass, PunktfunkKit builds, osinfo's tests pass. Not verified on glass,
and the GTK/Windows client crates do not build on macOS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:17:02 +02:00
enricobuehlerandClaude Fable 5 f4f83202cb feat(core/client): an AU's prefix reaches the decoder while its tail is on the wire
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
ci / web (push) Successful in 1m13s
apple / swift (push) Successful in 1m21s
ci / rust (push) Failing after 2m14s
android / android (push) Canceled after 2m26s
apple / screenshots (push) Canceled after 58s
arch / build-publish (push) Canceled after 2m42s
ci / docs-site (push) Successful in 2m36s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
ci / rust-arm64 (push) Canceled after 2m47s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 8s
deb / build-publish (push) Canceled after 2m50s
deb / build-publish-host (push) Canceled after 1m38s
deb / build-publish-client-arm64 (push) Canceled after 41s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 9s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 10s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 25s
release / apple (push) Canceled after 0s
windows-host / package (push) Canceled after 3m36s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 7s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
Delivery used to be all-or-nothing: the decoder saw byte 0 only after the
last packet of the AU landed, so the whole transmit time sat in front of
decode. With the slice-streamed wire (previous commit) blocks now arrive
addressable, and a client can opt in (connect's new frame_parts) to
receive each AU's newly-contiguous prefix as Frame::part pieces - offset
tiling, first/last marked, the completing push carrying only the suffix.
A PARTIAL_FRAME-capable decoder then chews slices concurrently with the
remaining network transfer.

The reassembler walks a per-frame cursor over successfully-completed
blocks (failed FEC reconstructs don't advance it), coalesces blocks that
finished out of order into one part, keeps probe filler whole, and stops
short of the final block so the zero-padded tail still trims at
completion. Whole-frame consumers see byte-identical behavior - parts
never flow without the opt-in, and never on PyroWave (its newest-wins
draining assumes whole AUs).

Per-AU accounting keeps its units: OWD/ABR feeds, the inter-arrival
series and the clock-based jump-to-live detector only count completing
deliveries, and FrameChannel::depth() counts AUs so a part-rich queue
can't trip jump-to-live at a fraction of the real backlog. The consumer
contract (gap or orphan part = AU lost: abandon, flush, resync on the
next first) is documented on FramePart; the C ABI keeps parts off until
PunktfunkFrame can express them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14:46 +02:00
enricobuehlerandClaude Fable 5 440cb8d1ec feat(core/wire): streamed AUs cut blocks at slice boundaries, not at 5.5 MB
The streamed-AU path only ever flushed a block when a full FEC block's
worth of bytes accumulated - at real bitrates a frame finished encoding
before that, so every AU degenerated to one tail-heavy send and the
overlap the sentinel wire was built for never happened.

Now a slice boundary flushes every whole shard (floored at
MIN_STREAM_BLOCK_SHARDS so per-block parity stays economical), which
means blocks are variable-size and can no longer be addressed by the
uniform block_index * K_max formula. USER_FLAG_SLICE_STREAM marks such
frames on every packet: sentinel blocks reuse frame_bytes as their
shard-aligned base offset, the final block's base derives from the
totals, and the receiver pins/validates positionally (sentinels must sit
strictly below the final block's base; a mixed-flag or out-of-range
header is dropped before it can place a byte).

The AU's own flag bit gates the wire shape, and the host sets it only
toward clients advertising STREAMED_AU + MULTI_SLICE, so shipped
receivers keep seeing the byte-identical legacy sentinel
(PUNKTFUNK_SLICE_STREAM=0 pins legacy for A/B). Both sides bound the
per-frame block count by the same floored formula, and the flush loop
cuts as many blocks as a chunk completes - a slice bigger than one FEC
block can't leave an oversized final block behind.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14:46 +02:00
enricobuehlerandClaude Fable 5 43868af1f5 feat(android): the client advertises multi-slice tolerance per-decoder
The P0 gating branch deliberately left Android off VIDEO_CAP_MULTI_SLICE
("embedder-set decoder truth") — this is that deferred item, and the P2
slice-pipeline prerequisite. VideoDecoders.multiSliceTolerant() probes the
pick for every advertised codec: tolerant unless any pick is an Amlogic
decoder (the 0.17.0 device-rebooting wedge) or uninspectable (a null pick =
platform default → conservatively single-slice). nativeConnect carries the
bit; the JNI ORs it into the Hello's video_caps beside the panel-truth
HDR bits. Phones (Qualcomm/Exynos) advertise it; the Amlogic TV boxes the
gate exists for never will.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 14:14:46 +02:00
enricobuehlerandClaude Fable 5 19392918ff feat(gamescope/hdr): HDR is attempted by default and every install path ships the patched gamescope
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m14s
ci / rust-arm64 (push) Successful in 3m28s
deb / build-publish-client-arm64 (push) Successful in 2m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 29s
android / android (push) Successful in 5m47s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m16s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 6m5s
docker / deploy-docs (push) Successful in 35s
deb / build-publish-host (push) Successful in 6m35s
ci / rust (push) Successful in 6m38s
arch / build-publish (push) Successful in 9m13s
apple / swift (push) Failing after 13m37s
apple / screenshots (push) Skipped
windows-host / package (push) Successful in 17m4s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 20s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 39s
Field report (RX 5700 / SteamOS): 10-bit HEVC "unsupported" — but the GPU was
never the blocker (RADV probed Main10 fine on that box). The session stayed SDR
because stock gamescope offers no 10-bit PQ capture formats and nothing shipped
our pipewire-hdr build outside the Bazzite sysext, while PUNKTFUNK_GAMESCOPE_HDR
additionally defaulted off everywhere. Close all three gaps:

- PUNKTFUNK_GAMESCOPE_HDR now defaults ON (explicit-off grammar, the flip the
  config comment planned post-canary). Safe by construction: the +pfhdr banner
  probe, managed-spawn term, and client 10-bit cap keep stock-gamescope boxes on
  the exact 8-bit path. The nix module's gamescopeHdr (default true now) only
  controls whether the patched binary is on PATH; it no longer sets the env.
- SteamOS: new scripts/steamdeck/build-gamescope.sh builds the HDR gamescope in
  the same pf2 trixie distrobox (apt dep list validated by a full build in a
  debian:trixie container), installs ~/.local/bin/punktfunk-gamescope, and
  maintains PUNKTFUNK_GAMESCOPE_BIN in host.env ONLY while the binary passes its
  on-glass --version/+pfhdr check — a stale absolute override would break
  session spawning, not just HDR. Wired into install.sh (§3b), update.sh
  (retrofit), and rebuild-check.sh (ldd-probes it like the host binary).
- Arch: build-sysext.sh gains a --gamescope fold-in (banner-verified by
  execution, host-image-only), and punktfunk-host optdepends the companion.
  Deliberately NO CI-published host sysext: per the 2026-07-22 packaging
  verdict a prebuilt SteamOS host breaks on A/B soname bumps (and /var is
  per-partition-set), so arch.yml records that instead — SteamOS hosts use the
  distrobox flow above.

Also fixes a latent bug that made build-sysext.sh fail every invocation as
documented: a literal `}` inside `${1:?usage …{host,client}…}` terminates the
expansion early and corrupts $PKG (caught by the new synthetic-package test,
which also covers the fold-in, the unmarked-gamescope refusal, and the
client-image guard).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 13:50:31 +02:00
enricobuehlerandClaude Opus 5 78926be4ac fix(store): a plugin install lands in the plugins dir, not the nearest package.json above it
ci / docs-site (push) Successful in 2m8s
ci / web (push) Successful in 1m15s
ci / rust-arm64 (push) Successful in 1m25s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 33s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 21s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 19s
deb / build-publish-client-arm64 (push) Successful in 4m21s
apple / swift (push) Successful in 4m52s
android / android (push) Successful in 5m22s
deb / build-publish (push) Successful in 3m57s
docker / builders-arm64cross (push) Successful in 8s
docker / deploy-docs (push) Successful in 26s
deb / build-publish-host (push) Successful in 3m55s
ci / rust (push) Successful in 9m19s
arch / build-publish (push) Successful in 9m25s
windows-host / package (push) Successful in 14m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m14s
apple / screenshots (push) Successful in 20m44s
Installing any plugin from the console failed on a user's Fedora 44 host with

  plugin store job failed: @punktfunk/plugin-virtualhere is not present after install

while the same version installed fine on every box we tried. The difference turned
out to be a file in his home directory.

`bun add` does not install into its working directory. It walks UP to the nearest
ancestor `package.json` and installs into that tree. A fresh plugins dir has no
`package.json` — the store and the runner only write `bunfig.toml` — so any stray
one above it captures the install: the packages land in `~/node_modules`, the
dependency is written to `~/package.json`, bun prints "installed …" and exits 0,
and the plugins dir stays empty. The plugins-dir `bunfig.toml` is not read in that
case either, so the outcome splits on whether `@punktfunk` resolves in the hijacked
tree: with no scope mapping there it 404s against npmjs and exits 1 ("the plugin
runner exited with status 1"); with one, it succeeds into the wrong tree and the
job dies on the presence check instead. Only the second shape looks like a broken
store, which is why this took a reproduction to find.

Seed the plugins dir with a `package.json` so it owns bun's install root. Three
call sites rather than one: the store job, the `plugins add` CLI, and the SDK's
`ensurePluginsDir`. The runner ships as its own package and can predate the host
binary, so the host cannot delegate this — the same reason `ensure_bunfig_scope`
writes the registry mapping on this side rather than passing a runner flag.

Seeding only ever touches a tree with no `node_modules`. A dir with packages and
no `package.json` is the hand-assembled/older layout that `installed_packages`
deliberately falls back to the naming convention for, and dropping an empty
`dependencies` on it reports every plugin the operator runs as uninstalled —
already pinned by `an_emptied_dependency_list_means_nothing_is_installed`.

The residual failure stops dead-ending: if the presence check fails anyway, the
error names the capturing ancestor and what to do about it.

Reproduced and fixed on glass (Nobara 44 VM, host + runner 0.22.3): with a stray
~/package.json the store reproduces the field report byte for byte — same 150 ms,
same phase sequence, same message — and with the plugins dir seeded the same
install lands correctly with nothing leaking into the home dir.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:48:25 +02:00
enricobuehlerandClaude Fable 5 759aac255b feat(phase-lock): controller v3 — grid-locked submits, arrival sensing, antipode damping
ci / docs-site (push) Successful in 1m17s
ci / web (push) Successful in 2m14s
ci / rust-arm64 (push) Successful in 2m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 19s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
deb / build-publish-client-arm64 (push) Successful in 2m59s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
apple / swift (push) Successful in 4m48s
deb / build-publish-host (push) Successful in 4m51s
android / android (push) Successful in 5m31s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 12s
docker / deploy-docs (push) Successful in 35s
deb / build-publish (push) Successful in 6m0s
ci / rust (push) Successful in 9m53s
arch / build-publish (push) Successful in 10m32s
windows-host / package (push) Successful in 19m10s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 15s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m17s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m2s
apple / screenshots (push) Successful in 21m3s
Midday on-glass falsified v2's remaining assumptions: a per-frame additive
hold SATURATES the arrival-slaved loop once hold + work >= interval — submits
free-run at their own cadence and the commanded phase dissolves (measured:
+-2 ms hold steps, zero client response) — and the latch statistic hovers at
the target's ANTIPODE, where shortest-way errors sign-flip on noise (the
0<->2<->4 ms offset chatter).

v3, one change per falsified assumption:
- ACTUATOR: submits lock to an absolute grid (epoch + k x period + offset);
  the controller walks only the offset. A periodic grid cannot free-run —
  occupancy is one frame per period whatever the offset — so phase actuation
  is linear BY CONSTRUCTION. Disengaged = no grid sleeps = zero cost; every
  failure path DISENGAGES (never parks: v2's e2e-tax lesson).
- SENSOR: the client reports the circular mean + coherence of the ARRIVAL
  lead (per-AU reassembly stamps vs the panel latch grid) — the phase the
  host actually controls; latch measured downstream of the decoder pipeline,
  which absorbed the actuation. pf.phase logs "arrival lead circ= coh=".
- ANTIPODE DAMPING: errors within 1 ms of +-period/2 take half-steps until
  they commit to a side.

Harness rewritten around plants glass validated: a GRID plant (linear by
construction), the DECOUPLED plant (v2's saturation — must disengage, not
orbit or park), an antipode start (must converge within one period of
travel), incoherence (never engages), a regime change (re-engages after
backoff), and the actuator's own periodicity/offset-linearity. Also fixes
the harness's SIM_TARGET (a mis-derived max asserted 3.5 ms where the
controller's actual target is 2.5 — the controller was right, the ruler
was wrong; the Python cross-check inherited the same constant, a lesson in
replicating the CODE, not the author's belief about it).

Gates: v3 simulations 6/6; host bin suite 345/1 (the documented
environmental qemu UDP test); clippy --all-targets -D warnings clean;
cargo ndk arm64 check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 12:20:39 +02:00
enricobuehlerandClaude Fable 5 4457356ee4 test(phase-lock): a closed-loop simulation harness — both on-glass failures become CI regressions
ci / web (push) Successful in 1m7s
ci / docs-site (push) Successful in 1m14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 23s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 27s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 2m48s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 22s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 25s
deb / build-publish-client-arm64 (push) Successful in 2m48s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m1s
android / android (push) Successful in 5m31s
deb / build-publish-host (push) Successful in 6m5s
docker / builders-arm64cross (push) Successful in 18s
apple / swift (push) Successful in 4m41s
docker / deploy-docs (push) Successful in 46s
ci / rust (push) Successful in 6m45s
deb / build-publish (push) Successful in 5m9s
arch / build-publish (push) Successful in 9m4s
flatpak / build-publish (push) Successful in 6m54s
windows-host / package (push) Successful in 11m3s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 25s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m10s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m35s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m27s
release / apple (push) Successful in 25m17s
apple / screenshots (push) Successful in 21m0s
The controller iterated twice on the user's gaming PC before this existed;
never again. punktfunk_core::phase::circular_latch is extracted from the
Android presenter (shared: iOS reports next, and the harness generates its
synthetic reports through the IDENTICAL statistic the clients ship), with
unit tests including the wrap-straddling cluster an arithmetic mean gets
maximally wrong.

The stream.rs harness models the plant — latch = (base − hold + noise)
mod P, deterministic LCG noise — and drives PhaseController::adjust through
five regimes:
- tight jitter locks into the deadband within ~5 adjusts and stops moving;
- a wrap-side start takes the SHORTEST way (travel < P/2 asserted);
- an incoherent phase never steps (zero hold, ever);
- a v1 pinned-median report trips the travel budget and DECAYS TO ZERO —
  the 2026-07-31 orbit and the parked-hold e2e tax, both as asserts;
- a post-decay regime tightening re-locks.

Gates: host bin suite 344 passed / 1 failed = the documented environmental
qemu UDP-loopback test (clean main fails identically in this container);
core phase tests 4/4; clippy --all-targets -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 11:18:30 +02:00
enricobuehlerandClaude Fable 5 fe552a50bb fix(android): the phase reporter actually sends the v2 circular statistic
ci / rust-arm64 (push) Successful in 2m45s
ci / web (push) Successful in 1m15s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / docs-site (push) Successful in 1m29s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 18s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 37s
docker / builders-arm64cross (push) Successful in 18s
android / android (push) Successful in 6m17s
ci / rust (push) Successful in 9m44s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 12m57s
docker / deploy-docs (push) Canceled after 0s
The v2 client edit missed its anchor against rustfmt's reshaping and the
reporter kept sending the v1 median — caught by the arm64 build the moment
the tuple signature landed. Sends (circular mean, coherence) now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:58:19 +02:00
enricobuehlerandClaude Fable 5 1d31e4c565 feat(phase-lock): controller v2 — circular phase, coherence gate, decay-to-zero
ci / web (push) Successful in 1m9s
ci / docs-site (push) Successful in 1m34s
ci / rust-arm64 (push) Successful in 1m39s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5s
android / android (push) Failing after 2m21s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 17s
ci / rust (push) Canceled after 2m52s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 34s
docker / builders-arm64cross (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 4m47s
deb / build-publish-client-arm64 (push) Successful in 3m59s
deb / build-publish-host (push) Successful in 4m29s
deb / build-publish (push) Successful in 5m59s
flatpak / build-publish (push) Successful in 4m58s
arch / build-publish (push) Successful in 10m18s
windows-host / package (push) Successful in 11m6s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m8s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m9s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m41s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m46s
apple / screenshots (push) Canceled after 0s
release / apple (push) Canceled after 19m16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 21m16s
Round-1 on-glass falsified two v1 assumptions in one morning (NP3 ↔ .173):

- A median lead is immovable under period-spanning jitter (uniform mod P),
  so v1 orbited the period at 2 ms/s forever — and the dead-signal freeze
  hotfix never armed, because ±1.5 ms of window sampling noise kept
  resetting its 'error responded' check. Thresholds can't referee noise.
- A HELD hold is not free: it delays sampling after the capture stamp,
  taxing e2e by up to a period (user-measured: ~+4 ms during the orbit).
  The correct failure response is DECAY TO ZERO, never freeze-in-place.

v2, all three layers:
- Wire: PhaseReport grows a length-discriminated coherence tail (27-byte
  form; the u16::MAX sentinel encodes as the byte-identical 25-byte v1 —
  strict-prefix discipline, both forms pinned by tests).
- Client: the reporter sends the CIRCULAR vector-mean latch phase mod the
  panel period + its coherence (‰); pf.present logs circ=/coh=.
- Host: steps only while coherent (floor 300‰; a v1 report bypasses the
  gate), along the signed SHORTEST way around the period, under a
  cumulative 1.25-period travel budget that is noise-immune by construction
  (it integrates applied steps, not reported errors); incoherence or budget
  exhaustion decays the hold to zero and re-arms once flat. Deadband
  convergence resets the budget — tight regimes lock exactly as designed.

report_phase + the C ABI mirror gain the coherence parameter (canary-only
ABI, hours old, sole caller in-tree). Gates: docker amd64 clippy
--all-targets -D warnings (host+core, nvenc) clean; core suite 244/244;
cargo ndk arm64 check clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:55:24 +02:00
enricobuehlerandClaude Fable 5 a5896f0883 feat(android): the phase-lock ACK is logged — the closed loop reads out in logcat
ci / web (push) Successful in 1m6s
ci / docs-site (push) Successful in 2m8s
deb / build-publish (push) Successful in 4m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
deb / build-publish-host (push) Successful in 4m25s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
ci / rust-arm64 (push) Successful in 5m46s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
deb / build-publish-client-arm64 (push) Successful in 3m40s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 31s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
docker / builders-arm64cross (push) Successful in 11s
docker / deploy-docs (push) Successful in 50s
arch / build-publish (push) Successful in 7m59s
android / android (push) Successful in 8m20s
ci / rust (push) Successful in 9m33s
apple / swift (push) Successful in 5m3s
windows-host / package (push) Successful in 12m57s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 30s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m59s
apple / screenshots (push) Successful in 21m18s
The host's applied hold rides the 0xCF tail but nothing client-side showed
it; 'adb logcat -s pf.phase' now logs transitions. This line is what exposed
the dead-median controller orbit on the first on-glass run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:12:56 +02:00
enricobuehlerandClaude Fable 5 74ca42568a fix(host/phase-lock): a dead error signal freezes the hold instead of orbiting the period
First on-glass closed-loop run (NP3 ↔ .173, canary 0.23.10498) proved the
transport and both endpoints work — and exposed the controller chasing a
statistic that cannot respond: under period-spanning arrival jitter the
client's latch DISTRIBUTION is ~uniform mod the refresh, so shifting its
mean (the hold — provably reaching the client: the paced-drop bursts locked
to each wrap) cannot move its MEDIAN. The controller stepped +2 ms/s in one
direction forever, wrapping the period every ~4 s, and every wrap coalesced
~30 frames at the client — a periodic hiccup that must not ship.

The controller now remembers its last error: three consecutive steps that
fail to move it ≥1.5 ms freeze the hold (kept, not zeroed) and log; a later
report whose error moved re-arms stepping — a tight-jitter regime still
locks exactly as designed. Controller v2 (a circular phase statistic with a
coherence gate, per the design doc's follow-ups) replaces the median
outright; this freeze stays as its safety net.

Gate: docker amd64 clippy --all-targets -D warnings (host, nvenc) clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 10:12:56 +02:00
21787ca6cd feat(decky): the plugin updates the actual client, not only the flatpak one
audit / bun-audit (plugin-kit) (push) Successful in 17s
audit / bun-audit (sdk) (push) Successful in 20s
audit / bun-audit (web) (push) Successful in 27s
audit / pnpm-audit (push) Successful in 18s
audit / cargo-audit (push) Successful in 49s
audit / docs-site-audit (push) Successful in 42s
apple / swift (push) Successful in 1m18s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Successful in 2m10s
audit / license-gate (push) Successful in 5m17s
ci / rust-arm64 (push) Successful in 5m33s
decky / build-publish (push) Successful in 35s
deb / build-publish (push) Successful in 5m10s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 5s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
deb / build-publish-client-arm64 (push) Successful in 2m16s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m10s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 26s
android / android (push) Successful in 9m7s
docker / builders-arm64cross (push) Successful in 8s
arch / build-publish (push) Successful in 9m19s
docker / deploy-docs (push) Successful in 31s
deb / build-publish-host (push) Successful in 7m31s
windows-host / package (push) Failing after 14m54s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
flatpak / build-publish (push) Successful in 7m57s
ci / rust (push) Successful in 15m50s
release / apple (push) Successful in 18m41s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 7m8s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m10s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m52s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m49s
apple / screenshots (push) Canceled after 9m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m55s
The QAM already offered a client update — for the flatpak, by comparing OSTree
commits. A native client (.deb, .rpm, pacman, sysext, nix, a source build), which
the plugin has been able to LAUNCH since it learned to drive one, went stale in
silence: no version, no check, no mention.

check_update now branches on the client the launcher will actually run. The
flatpak keeps its commit comparison, which is the exact one — a flatpak built
from main between releases carries the release's crate version, so a
version-based check would call it current when it isn't. Everything else asks
the client itself, which verifies the signed manifest. update_client routes the
same way: flatpak update --user, or `punktfunk-client --apply-update`.

Where nothing on the box can install it, the plugin says so instead of offering
a button. The QAM banner becomes the command, the About tab names the install
kind so "why is there no Update button?" has a visible answer, and a check that
FAILED reads as a failure — never as "up to date".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:41:26 +02:00
b164b4c03e feat(packaging): a client-only box can update its client without the host
The root helper, its unit, the group-scoped polkit rule and the install-kind
marker all shipped with the HOST package. A Steam Deck or a handheld has no host
package, so the client half of one-tap updates had nothing to stand on.

The client packages now ship their own copies — deliberately under their own
paths (`pf-update-client`, `punktfunk-client-update.service`,
`49-punktfunk-client-update.rules`, `/usr/share/punktfunk-client/install-kind`)
rather than sharing the host's. Two packages owning one path is a hard conflict
in dpkg, rpm and pacman alike, and the client marker needs its own DIRECTORY
too because the host RPM claims `%{_datadir}/punktfunk/*` with a glob — a
sibling file there would break `dnf install punktfunk punktfunk-client`.

`pf-update` grows the matching `apply-client` verb. The verb comes from a
root-owned unit's fixed ExecStart, never from the caller, so the zero-parameter
invariant holds; both verbs sweep every installed punktfunk package, and what
the verb selects is which marker is read and which binary the run-the-binary
gate executes afterwards. A client sysext is refused with its reason: the signed
feed carries the host image, and installing that over a client-only box is not
an update.

The opt-in group is created by both packages' scriptlets, idempotently, so
whichever lands first wins.

Also fixes the host's own helper install: it read `$R/target/release/pf-update`,
which is the source checkout, while build() exports CARGO_TARGET_DIR="$srcdir/
target" and every other binary in the file installs from `$T`. Both now read
`$T`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:41:26 +02:00
a87fbebc0a feat(client/linux): the client says whether it is current, and can update itself
`punktfunk-client --check-update` fetches the same Ed25519-signed per-channel
manifest the host trusts, works out how this client was installed, and reports
what — if anything — can be done about a newer build. `--apply-update` does the
part it owns. `--version` prints the CI-stamped build string, which is both what
the comparison needs and what a packaging gate can run the binary for.

The engine lives here rather than in the Decky plugin for two reasons: verifying
a signature needs crypto that Decky's embedded Python does not have, and the
plugin is not the only surface that wants the answer. Every UI becomes a caller,
exactly as it already is for --pair, --library and --list-hosts.

What is possible depends on the install, and the module refuses to pretend
otherwise. A flatpak is a per-user install its launcher can update. A .deb/.rpm/
pacman client goes through the packaged root helper — a fixed, parameterless
systemd oneshot polkit authorises for members of the punktfunk-update group, so
the request carries nothing an attacker could influence and the payload comes
from the distro's own signed repositories. A sysext, a nix profile or a source
build gets the command and nothing else: the signed sysext feed carries the HOST
image, so a client sysext has no feed to update from, and a button that can only
fail is worse than one honest line.

A failed check reports the failure. "Could not tell" rendering as "up to date"
is the one answer that must never happen.

The box's capabilities are probed once into a `Caps` struct so the routing rules
are a pure function of (install kind, caps) — all seven of them are tested
without a box, including that the kill switch beats every kind and that pacman
needs its own root-owned full-sysupgrade opt-in on top of the group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:41:26 +02:00
f6cfe382fd refactor(update): one signed-manifest checker, shared by the host and the client
The host has known how to answer "is a newer build available for this box's
channel?" since the update-from-web-console work. The Linux client is about to
need the same answer from the same signed document, and a trust rule that lives
in two places is a trust rule that will drift.

So the parts where being wrong is a security bug now exist exactly once, in the
new `pf-update-check`: Ed25519 verification against pinned keys, the manifest
schema and its fail-closed validation, the post-redirect fetch, the version
comparison that has to reconcile four different canary spellings, and the
install-kind ladder — the last parameterised by which product is asking, since
the delivery channels are the same ones but the markers are not.

The pinned key list moves with them. Two lists could have disagreed about who
may announce a release, and the one that drifted is the one nobody would have
noticed; `publish-update-manifest.sh` follows to the new path, and a MISSING
keys file there is now fatal rather than a warning that silently skips the
cross-check the step exists to perform.

Host call sites are unchanged throughout: `store::index` and `update::manifest`
re-export from the shared crate under their old names, and `update::detect`
keeps its cached `detect()` and the host's command hints. Verified with clippy
-D warnings on Linux and on Windows (nvenc,amf-qsv,qsv), 340 host tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 09:41:26 +02:00
enricobuehlerandClaude Fable 5 84c6938562 test(inject): the uhid sweep tests learn the devnode-churn grace
arch / build-publish (push) Failing after 6s
ci / docs-site (push) Successful in 1m53s
ci / web (push) Successful in 1m54s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
ci / rust-arm64 (push) Successful in 2m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 35s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 15s
deb / build-publish-client-arm64 (push) Successful in 1m32s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 3m50s
docker / deploy-docs (push) Successful in 34s
deb / build-publish-host (push) Successful in 4m26s
apple / swift (push) Successful in 4m54s
android / android (push) Successful in 7m2s
ci / rust (push) Successful in 9m15s
windows-host / package (push) Successful in 19m29s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 16s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m18s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m39s
apple / screenshots (push) Successful in 20m39s
6b0bd59b gave the unplug sweep a 300 ms grace so a mask glitch can't flap
PnP devices — and updated pad_slots' own tests, but not the two uhid_manager
consumer tests that encode the old immediate-sweep contract (its CI run was
cancelled by a superseding push, so the breakage surfaced on later shas).

PadSlots gains a #[cfg(test)] expire_grace() that backdates the armed clocks,
so consumer tests drive the debounce without wall-clock sleeps. The removal
test now also PINS the new hold-inside-grace behavior explicitly.

Verified in the amd64 CI-image container: pf-inject 83/83, clippy
--all-targets -D warnings clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 01:35:51 +02:00
enricobuehlerandClaude Fable 5 6b0bd59b8d fix(gamepad): a mask glitch no longer flaps devnodes — the unplug sweep gets a grace
ci / docs-site (push) Successful in 1m12s
ci / web (push) Successful in 1m26s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 27s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 8s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
ci / rust-arm64 (push) Successful in 2m14s
deb / build-publish-client-arm64 (push) Successful in 1m6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish (push) Successful in 3m38s
docker / builders-arm64cross (push) Successful in 7s
docker / deploy-docs (push) Successful in 25s
deb / build-publish-host (push) Successful in 6m44s
android / android (push) Successful in 7m55s
ci / rust (push) Failing after 8m1s
windows-drivers / probe-and-proto (push) Successful in 30s
apple / swift (push) Successful in 4m43s
windows-drivers / driver-build (push) Successful in 1m47s
arch / build-publish (push) Failing after 5m39s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m29s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m45s
windows-host / package (push) Successful in 12m1s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 19s
apple / screenshots (push) Successful in 20m47s
The unplug sweep dropped a pad the instant its active_mask bit read
clear — but a pad teardown is a whole PnP device removal (system-wide
device-change broadcasts, and on re-create a full hidclass
re-enumeration), so a client-side mask glitch of a few state frames
cycled real devnodes. The sweep now drops a pad only after its bit has
stayed clear for 300 ms; a bit that returns inside the grace disarms it.
A real unplug tears down one grace later, which nobody observes — the
pad already went quiet. Lands once in PadSlots, so every backend
(uinput/uhid/XUSB/UMDF) inherits it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:59:50 +02:00
enricobuehlerandClaude Fable 5 15361b0091 fix(drivers/gamepad): drop the 1 s enumeration wait its own premise doomed, and gate the logger
The PnP-identity fix established that the sealed channel is STRUCTURALLY
unavailable while hidclass enumerates (the DATA section arrives over the
HID interface those very queries create) — so device_type()'s bounded
1 s pump loop, reachable only for a devnode whose hardware ids matched
nothing, could never succeed there: it burned a second of a WUDFHost
dispatch thread mid-enumeration and then fell back to LAST_DEVTYPE
anyway. The fallback chain is now wait-free (attached section → PnP
identity → LAST_DEVTYPE), and the 8 ms timer refreshes LAST_DEVTYPE
whenever the section is attached, so the last resort is always current.

The logger also joins its siblings behind file_log_enabled(): a RELEASE
driver without the opt-in no longer OutputDebugStringA's (+ CString +
format! allocs) per logged event — the rumble OUTPUT hex dumps and the
cyclic GET_STRING polls were sustained paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:59:50 +02:00
enricobuehlerandClaude Fable 5 ccf5c922ee fix(core/abi): report_phase joins its siblings behind the quic cfg — the C header parses again
ci / docs-site (push) Successful in 1m6s
deb / build-publish-host (push) Failing after 14s
ci / web (push) Successful in 2m28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 10s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
ci / rust-arm64 (push) Successful in 4m30s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m13s
apple / swift (push) Successful in 4m46s
android / android (push) Successful in 5m32s
docker / builders-arm64cross (push) Successful in 11s
deb / build-publish (push) Successful in 6m8s
docker / deploy-docs (push) Successful in 35s
ci / rust (push) Successful in 7m7s
arch / build-publish (push) Failing after 9m7s
flatpak / build-publish (push) Successful in 7m3s
windows-host / package (push) Successful in 18m52s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 12s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m25s
apple / screenshots (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 16m55s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m23s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m50s
release / apple (push) Successful in 25m47s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m44s
The new punktfunk_connection_report_phase lacked the #[cfg(feature = "quic")]
its connection-fn siblings carry, so cbindgen emitted it OUTSIDE the guarded
region that declares the opaque PunktfunkConnection — the generated header
stopped compiling as C (CI's harness caught it; my local harness failure was
a pre-existing macOS linker issue that masked this distinct cause — full-suite
comparison attributed the wrong root).

Verified: the harness compiles and passes in the amd64 CI-image container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 00:38:31 +02:00
enricobuehlerandClaude Fable 5 fa822744ff feat(core/host/android): phase-locked capture — frames arrive on the client's latch schedule
ci / web (push) Successful in 1m59s
ci / docs-site (push) Successful in 2m13s
ci / rust-arm64 (push) Successful in 5m17s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 4s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
android / android (push) Successful in 5m52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
deb / build-publish (push) Successful in 5m38s
ci / rust (push) Failing after 5m52s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
arch / build-publish (push) Failing after 6m7s
deb / build-publish-client-arm64 (push) Successful in 3m55s
docker / builders-arm64cross (push) Successful in 19s
deb / build-publish-host (push) Failing after 4m24s
apple / swift (push) Successful in 4m35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m13s
docker / deploy-docs (push) Successful in 31s
windows-host / package (push) Failing after 6m22s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m35s
flatpak / build-publish (push) Successful in 6m33s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m22s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 10m8s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Failing after 11m43s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m1s
release / apple (push) Successful in 25m5s
apple / screenshots (push) Successful in 20m50s
The host's capture tick and a client's panel vsync are independent ~120 Hz
oscillators; their drifting phase sweeps every frame's wait-for-latch across
a full refresh period (measured on-glass: latch p50 oscillating 5.4-8.9 ms
with a fixed margin) — the beat is the residual judder and the fat p95, and
no client can fix it alone. Design: punktfunk-planning
design/phase-locked-capture.md.

Protocol (punktfunk-core):
- PhaseReport (control 0x32, next to the clock family): the client's next
  display latch ALREADY CONVERTED to host clock (the skew offset lives only
  client-side), panel period, uncertainty, and the measured median
  arrival-lead — the controller's error signal. ~1 Hz, latest-wins.
  CLIENT_CAP_PHASE_LOCK advertises it; CtrlRequest::Phase + report_phase()
  + the C ABI mirror carry it.
- The 0xCF host-timing tail grows a phase ACK (applied_phase_ns, 29-byte
  form) under the same strict-prefix append discipline — old readers parse
  the shorter forms; degradation pinned by tests.

Host engine (arrival-slaved loop — no backend can move the source vsync,
per the tick-ownership audit in the design doc):
- PhaseCtl bridges control task → encode loop (the fec_target pattern,
  multi-field). PhaseController walks a per-frame HOLD before submit toward
  the client's reported lead hitting target = max(2.5 ms, uncertainty+1ms):
  1 Hz adjust, 2 ms max step, 300 µs deadband, period-wrapping (the
  newest-wins capture slot makes a wrapped hold sample fresher content, not
  staler). A loop local, so every mid-stream rebuild keeps the lock; a new
  session re-acquires. PUNKTFUNK_PHASE_LOCK=0 disarms.

Android reporter: the presenter's 1 Hz pf.present flush returns the
window's measured latch p50; the async loop converts the vsync clock's next
timeline monotonic→realtime→host and reports. Inert toward old hosts.

Gates: docker amd64 clippy --all-targets -D warnings (host+core, nvenc)
clean; core suite incl. the new wire tests; cargo ndk arm64 check/clippy
clean. On-glass A/B vs the .173 host owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 f9faab780e tune(android): the latch margin drops to SF's real lead (2.5 ms)
Each ms of submit-margin is a ms on every frame's display stage; SF's latch
runs ~1-2 ms before present and the release is a sub-ms binder call, so 4 ms
was padded. Measured (A024, 120 Hz game load): latch p50 8-10 → 5.4-8.9
(phase-drift dependent), paced stays 1-5/s. A device that misses at this
margin shows it as a paced-counter rise, not stutter — the widen signal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 a6ff0350e4 feat(android): frames target SurfaceFlinger's latch, not the GPU-render deadline — display 21→9.5 ms
The remaining ~21 ms display stage was the conservative release target: the
presenter aimed at the first frame timeline whose DEADLINE was still ahead,
and the platform's deadline budgets for GPU rendering the app has yet to
submit (presDeadline = 11.3 ms on the A024 — more than a full 120 Hz
period). A decoded video buffer has no GPU work left; its only real
constraint is SurfaceFlinger's own latch lead. Every frame paid a whole
extra refresh of waiting for a budget it never used.

next_target now gates (and subdivides) on the timeline's EXPECTED PRESENT
minus a 4 ms latch margin; the glass budget reopens at that latch instant
(expected present − margin) rather than the deadline, which under the
aggressive gate can already lie in the past — an instant reopen would let
two releases pile onto one vsync. A mis-gamble presents one vsync later,
which is exactly what the deadline gate paid on every frame — the trade is
one-sided.

On-glass (A024, 2800×1260@120 HDR, game load): latch p50 21→8-10 ms,
display 26,3→9,5 (pace 0,8 + latch 7,7), e2e 43,7→26,4 p50 / 29,2 p95,
released=displays=120, paced≈0, forced=0. The latch now sits under one
refresh interval — the vsync-latch floor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 984f7be896 fix(android): the presenter paces to the panel's real grid, not the app's down-rated vsync
On-glass (A024, 120 Hz panel, 120 fps session) the first presenter build
released only 60/s and the HUD display term hit 40 ms. Root cause, in two
layers: Android down-rates a game-category uid's choreographer stream to
60 Hz (frame-rate categories / game default frame rate), and under that
override Display.getRefreshRate REPORTS THE OVERRIDE — so the presenter's
panel grid read 16.67 ms on an 8.33 ms panel and the subdivision became a
no-op, pacing the video at half rate and dropping every other frame.

Three-part fix, verified live on the same device:
- Kotlin passes the panel rate from the supported-modes TABLE
  (MainActivity.streamPanelFps — the mode list is not override-filtered)
  instead of display.refreshRate, and votes the app's render rate up via
  View.requestedFrameRate = streamHz (API 35+) while streaming.
- The native vsync clock LEARNS the panel period from observed timeline
  spacing (downward-only: the finest spacing SurfaceFlinger ever reports is
  the true grid) and next_target subdivides the reported timeline onto it —
  full-rate on down-rated devices, a no-op where callbacks match the panel.
- OnFrameRendered display/latch samples get the e2e clamp (0..10 s): a
  vendor's first callbacks can carry a garbage system_nano (observed: an
  epoch-sized latch max) that would poison every max it lands in.

pf.present gained panelMs next to vsyncMs, and a one-shot cadence
diagnostic logs Δ/timelines/spacing/panel on the third tick.

After: released=120 displays=120 paced=0, pace p50 <1 ms, latch p50 ~16 ms
idle / ~22 ms under game load (2 refresh intervals at 8.33 — the same
composited-pipeline law the Apple client measured), HUD display ~17-26 ms
vs 40 before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 e08fd91cd1 feat(android): a timeline presenter — frames reach glass on the panel's schedule, not decode's
The Android port of the Apple client's stage-4 deadline discipline, closing
the side-by-side feel gap (both clients 120 Hz; Android released decoded
buffers the instant they appeared, with zero vsync awareness — the latch
phase inherited every network+decode jitter and bursts queued behind the
display).

The presenter (async loop only; the sync loop stays the untouched escape
hatch behind the Low-latency toggle):

- decode/vsync.rs: an AChoreographer thread (dlsym'd like the other
  above-floor symbols) publishing the panel's vsync grid + frame timelines
  (postVsyncCallback, API 33; postFrameCallback64 fallback on 31/32) and
  ticking the decode loop's event channel. Started lazily on the first
  decoded frame.
- decode/presenter.rs: a newest-wins slot (Lowest latency, default) or a
  1-3 frame smoothing FIFO with preroll/underflow re-arm (Smoothness) between
  decode and release; a glass budget of exactly ONE undisplayed release in
  flight, reopened at the target timeline's DEADLINE (SurfaceFlinger's latch
  — reopening at present time would halve the sustainable rate) with a 100 ms
  stale force-open backstop; the release itself via
  releaseOutputBufferAtTime(expectedPresent) so the latch phase is
  deterministic. debug.punktfunk.presenter=arrival sysprop restores the
  legacy path for a rebuild-free on-device A/B.
- Metrics: DisplayTracker is now always-on and carries the release stamp, so
  the display stage splits into pace (decoded→release) + latch
  (release→displayed); a 1 Hz pf.present logcat line (released/displays/
  paced/noBudget/forced/qDry + pace/latch p50/max + measured vsync) makes a
  HUD-off wireless A/B readable; nativeVideoStats grows to 30 doubles
  (26=paceP50, 27=latchP50, 28=presents, 29=presenterActive; 0-25 frozen)
  and the DETAILED HUD prints the split + presents.
- Intent parity: present_priority/smooth_buffer — the Apple client's
  stored values and labels — as globals, profile-overlay fields (round-trip
  + scope markers), and Settings pickers under Decoding; threaded through
  nativeStartVideo into the presenter config.

Verified: cargo ndk check/clippy clean for arm64 (the two type_complexity
warnings are pre-existing audio/mic ones), armv7 via the kit gradle task,
host cargo check clean, rustfmt clean, gradle :app/:kit unit tests all pass.
On-device before/after on the Nothing Phone 3 still owed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 581320df0c fix(android): the stream stops fighting large displays and owns the cutout explicitly
The three Play Console pre-release findings for 0.22.3, resolved:

- Orientation restriction: the in-stream SENSOR_LANDSCAPE lock is now applied
  on compact devices (sw < 600 dp) only. On tablets/foldables/desktop windows
  it is a large-display anti-pattern (Android 16+ ignores it there outright)
  and unnecessary — the aspect-ratio letterbox renders correctly in any
  orientation; the lock was always a phone-ergonomics choice. No manifest
  restriction existed, and resizeability stays unrestricted.
- Edge-to-edge determinism: the stream window now sets
  LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS explicitly (and restores it on the way
  out) — SDK-35 enforcement makes that the immersive default, pre-15 devices
  letterboxed the notch as a dead bar; being explicit gives both the same,
  correct behaviour. The stream's own letterbox is black, so the cutout region
  can never show anything wrong.
- Deprecated edge-to-edge APIs: audited — no in-app use of
  setStatusBarColor/setNavigationBarColor/systemUiVisibility/translucent
  flags or theme attrs; enableEdgeToEdge (androidx.activity 1.13.0) is already
  in place with explicit SystemBarStyle. What the scanner sees are androidx's
  own API-level-guarded compat branches, which Google documents as ignorable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehlerandClaude Fable 5 87b6fa8813 feat(android): the panel is pinned to the stream's refresh, and touch skips the vsync batch
Three quick latency wins for phones, ahead of the presenter rebuild:

- setStreamDisplayMode: the window-level preferredDisplayModeId is pinned to
  the stream's refresh (exact rate, else the smallest integer multiple, else
  the highest available) for the session. The surface-level frame-rate hint
  alone is advisory and some OEM refresh governors (Nothing OS's LTPO logic
  among them) ignore it for third-party apps — leaving a 120 Hz session
  presenting on a 60/90 Hz panel. nativeVideoSize gained a trailing
  refreshHz element for this (old readers index only 0/1). TV keeps the
  native HDMI mode switch instead.
- The surface hint itself now passes compatibility = FIXED_SOURCE on every
  form factor: the stream is fixed-rate video the client cannot re-pace;
  DEFAULT invited governors to not switch.
- requestUnbufferedDispatch(SOURCE_CLASS_POINTER) on the hosting view while
  streaming: touch/pointer events were vsync-batched — up to a frame of
  input latency the stream shouldn't pay.
- The HUD polls the panel's live refresh each second and flags '⚠ panel N Hz'
  when it sits below the stream rate, so an unpinned panel is visible instead
  of reading as inexplicable judder. Stale nativeStartVideo kdoc (low-latency
  'off, the default') corrected — it defaults ON under low_latency_mode_v2.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:44:09 +02:00
enricobuehler 2748b84933 style(vdisplay/driver): rustfmt one line the audit bundle left unwrapped
ci / web (push) Successful in 1m6s
android / android (push) Canceled after 2m41s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m43s
ci / rust (push) Canceled after 2m41s
ci / rust-arm64 (push) Canceled after 2m41s
ci / docs-site (push) Canceled after 2m39s
deb / build-publish (push) Canceled after 1m33s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 0s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 23s
windows-drivers / driver-build (push) Successful in 1m46s
`5742ec95` landed `create_monitor`'s EDID call split across two lines where it
now fits on one, so the drivers workspace fails `cargo fmt --all --check`.
Whitespace only — separated from the gamepad work it happened to block.
2026-07-30 23:40:35 +02:00
enricobuehler 98147fb89c fix(gamepad/windows): the channel proof sizes its feature buffer from the descriptor
`HidD_GetFeature`/`HidD_SetFeature` reject any buffer shorter than the
collection's `FeatureReportByteLength`, and the proof hardcoded 64. That held
only because every pad enumerated with the DualSense descriptor; a Deck that
enumerates as a Deck reports 65 (its one feature report is unnumbered and 64
bytes wide, plus the report-id slot Windows always reserves), so both calls
would have failed, the proof with them, and — the gate being fail-closed — the
pad would never receive its DATA section at all.

Take the length from the descriptor, and accept the Deck's reply at either
offset 0 or 1: one driver binary serves four identities and Windows places an
unnumbered report's payload behind the report-id slot.

An answer must also VALIDATE, not merely parse. `from_feature_report`
reinterprets any 17 bytes, and a Deck serves its one unnumbered feature report
for *any* requested id — so it answers the PS `0x85` probe with Steam attribute
bytes that parse into a proof and fail on magic. Returning that first answer
stopped the search while the real proof sat one transport away.
2026-07-30 23:40:35 +02:00
enricobuehler 00c29f82f2 fix(gamepad/windows): a pad enumerates as the controller it IS, not always a DualSense
A Steam Deck client streaming to a Windows host had a stuck stick and a stuck
d-pad. The virtual pad was enumerating with the DualSense VID/PID **and the
DualSense report descriptor**, so Windows parsed the 64-byte Deck frame as
DualSense report 0x01: LX = report[1] = 0x00 (stick hard left), LY = report[2] =
0x09 (hard up), and a d-pad hat of 0 — which is UP, held forever.

The driver picked its identity from `device_type` in the sealed section, but
hidclass asks for the descriptors and attributes while it STARTS the device, and
the section can only be delivered over the HID device interface — which does not
exist until those queries are answered. The channel was structurally unavailable
at the only moment it was needed, so `device_type()`'s bounded wait always timed
out and every identity fell back to DualSense. Not a race: DualShock 4 and the
Edge enumerated as DualSenses too (verified on .173 — both report 054C:0CE6 with
a 64-byte DualSense input report, while their on-demand strings read correctly).

The devnode's own hardware ids carry the identity and are readable at
EvtDeviceAdd, before anything is asked, so resolve it there. The section stays
authoritative once attached; the old wait survives only for a devnode whose ids
match nothing.

`hwid_devtype_table_matches_the_driver` pins the host's hwid → device_type
mapping against the driver's table, including the ordering trap that
`pf_dualsense` is a prefix of `pf_dualsenseedge`.
2026-07-30 23:40:35 +02:00
enricobuehlerandClaude Fable 5 5742ec9548 fix(vdisplay/driver): the audit bundle — one timing formula, honest EDID, scoped watchdog, lock-free drain, D0-resume re-init, knobbed RT priority
ci / web (push) Successful in 1m5s
windows-drivers / probe-and-proto (push) Successful in 1m5s
deb / build-publish-host (push) Failing after 12s
apple / swift (push) Successful in 1m55s
ci / docs-site (push) Successful in 2m56s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
windows-drivers / driver-build (push) Successful in 2m10s
ci / rust-arm64 (push) Successful in 3m22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 7s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 16s
deb / build-publish-client-arm64 (push) Successful in 2m44s
android / android (push) Canceled after 4m33s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 4m35s
ci / rust (push) Canceled after 4m40s
deb / build-publish (push) Canceled after 4m40s
docker / builders-arm64cross (push) Canceled after 2s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 42s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 39s
windows-host / package (push) Canceled after 1m21s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 3m45s
release / apple (push) Canceled after 5m40s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 2m41s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
One signing pass over the 2026-07-30 audit findings:

- Timing math unified (D4): monitor-description and target modes now come
  from ONE IddSampleDriver-exact builder differing only in
  vSyncFreqDivider; the virtual-display-rs legacy formula (width-less
  pixel rate, deliberately fractional vSync) is gone.
- EDID (D5): the preferred-timing DTD is built from the SESSION's mode
  when it fits the encoding (pf-driver-proto's tested builder; 1080p60
  stays the fallback); the range-limits descriptor covers everything the
  driver can advertise (max clock 150 MHz → 2550 MHz, max-H +255 —
  the old limits were violated by the driver's own 1080p120 default);
  product code 0 → 1. Deliberately still no HDMI VSDB — documented in
  the module doc.
- INF (D6): UmdfFileObjectPolicy=AllowNullAndUnknownFileObjects added
  (the sibling drivers all carry it); the dead DeviceGroupId (inert under
  ProcessSharingDisabled) dropped; the IddCx0102-vs-
  IddMinimumVersionRequired=10 pairing documented as deliberate — 0102
  is the extension's registered identity, not a version request.
- Watchdog lifecycle (D7): device cleanup now stops the host-liveness
  thread (it ran forever and its reap raced device teardown over the
  same monitor list).
- Drain path off the mutex (D8): the per-frame has/take_frame_channel
  checks (≥60 locks/s per worker on the mutex the whole control plane,
  the mode DDIs and the watchdog contend) are gated by a delivery
  generation counter — the steady state takes no lock.
- Adapter cache (D9): last-write-wins slot instead of a OnceLock, and a
  D0 re-entry from a REAL low-power state clears + re-inits — the stale
  pre-power-cycle handle used to wedge every later IOCTL_ADD.
- Realtime GPU priority (D10): IddCxSetRealtimeGPUPriority is now
  A/B-able without a rebuild (PFVD_NO_RT_GPU, machine env) — no
  canonical IDD driver raises it, and it preempts the game's and DWM's
  queues at a level apps can't reach.
- Logging (D2): the logger rides file_log_enabled() as a whole — a
  RELEASE driver without the opt-in no longer OutputDebugStringA's (+2
  allocs) per logged event.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 1c15ba89c3 fix(drivers/pads): the XInput path stops re-mapping the mailbox, and release logging gets a real off switch
pf-xusb pumped the sealed-channel bootstrap mailbox — an open+map+close+
unmap plus two heap allocs — on EVERY XInput IOCTL, per pad. A periodic
WDF timer (the pf-gamepad pattern) now owns the pump: adoption,
re-delivery and host-gone detection happen there, and the IOCTL path
reads the cached view. A vanished host still reads as a neutral pad
within one 8 ms tick, and the heartbeat mark still advances per serviced
IOCTL, so the host keeps seeing the GAME-visible polling path move.

Both pad drivers' log() also called OutputDebugStringA unconditionally —
a syscall + CString + format! alloc per logged event in RELEASE builds,
on per-IOCTL paths (SET_STATE hex dumps during rumble, the cyclic
GET_STRING polls). The whole logger now rides the existing
file_log_enabled() gate (debug builds, or PF*_DEBUG_LOG), and dbglog!
skips its format! too.

(pf-gamepad's identical log shape is deliberately untouched: the
unpushed fix/deck-hid-identity branch owns that file right now.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 85dd75437b feat(driver-proto): an EDID detailed-timing builder for the session's mode
An 18-byte DTD with fixed reduced blanking (80 px / 45 lines — a virtual
display's blanking only has to be self-consistent), refusing modes the
encoding cannot carry (pixel clock past the u16 10 kHz field, 4K120-class;
>12-bit actives). Lives here rather than in the driver so it is unit-
tested on every platform; pf-vdisplay splices it over its hard-coded
1080p60 preferred-timing descriptor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 8140d3f8b3 feat(capture/windows): a degraded stretch logs one summary line at recovery
Per-hole stall lines gate on prior ACTIVE flow, so inside a sustained
~2 fps phase only the first hole is reported and the log goes quiet
exactly while the user suffers — the field shape (deep 15 s stretches at
2 fps) was invisible without a stats recording.

StallWatch now tracks the stretch: opened by a reported stall, fed by
every stall-sized hole while the activity gate stays broken, closed when
sustained flow returns (or a ring recreate cuts it — its holes predate
the recreate and still count). Closure surfaces one INFO line: span,
hole count, summed hole time, worst hole. One-hole stretches dissolve
silently (the stall's own line covers them), and a ≥10 s gap closes the
stretch first so a quit-to-desktop pause never folds into the tally.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 789dafc95f feat(capture/windows): the stall micro-probes get an off switch, the depth pin a backoff
Two standing costs from the interval-stutter investigation, consolidated:

- PUNKTFUNK_STALL_PROBES=0 now opts a box out of the micro-probe engine
  (per-GPU 10 Hz fence copies, a parked blocking-DwmFlush waiter, the
  5 ms-cadence CPU sentinel). Default stays ON while the field program
  runs; off, stall lines keep the driver telemetry and the cheap ETW
  present/queue discriminator and only lose the corroborating probe legs
  — the verdict matrix already treats an absent probe window honestly.

- The negotiated-depth pin-back stops hammering a display that refuses
  the flip: it was 4 CCD writes + 8 display-config queries per second,
  forever, all on the session-global display-config lock. After ~2 s of
  eager retries it re-attempts every ~4 s — still self-healing, without
  the drumbeat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 ecb3d1ab22 fix(host/windows): the pad service thread no longer waits on pnputil at its leisure
driver_store_inventory() ran `pnputil /enum-drivers` synchronously inside
the attach-failure diagnose path — on the pad service thread, with no
timeout. A busy or wedged driver store blocks that enumeration for tens
of seconds, and the thread must keep draining pad slots.

The query now runs on its own thread (spawned once per process); the
caller waits a bounded 2 s so a fast pnputil still lands in the same
report, and a slower one fills the cache for later diagnoses — the WARN
then says the store could not be queried yet instead of blocking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 e71f4696ee fix(host/windows): an unlightable sink stops the teardown warn+force loop
A box whose only external display is an off/standby TV warned "no
external physical display active after the restore (connected=1)" and
forced the EXTEND preset on EVERY teardown — an inert remedy (with one
connected display the preset can't relight anything) that reads like a
fresh failure in every field log.

The backstop now measures its own effect: after a force-EXTEND it
re-reads the inventory, and a dark set that stayed dark THROUGH the
preset is latched as unlightable — subsequent teardowns log a debug line
and leave the topology be. The poisoned-snapshot chain the backstop
exists for is not latched away: there the panel CAN light, so the first
force succeeds and the latch stays clear. Process-lifetime latch, so a
host restart re-probes once.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 d014cea489 feat(core/abr): a keyframe-begging decoder backs the bitrate off, loss or no loss
The RX-9070 field trace: 14 decode-recovery keyframe requests in 2 s at
~300 Mbps with loss_ppm=0 — and the controller held the rate, because no
loss/OWD/latency signal moved. Repeated keyframe asks ARE the decoder
saying it cannot keep up; now they are an ABR input.

The control task counts outbound CtrlRequest::Keyframe at its send choke
point (the one place every emitter funnels through); the pump drains the
count per 750 ms report window into on_window. Two asks in a window is
ordinary-bad (two-window confirmation, like an OWD rise); four or more is
severe (the emitters throttle at 100 ms, so 4+ means the window was spent
begging) and backs off ×0.7 after one window. RFI asks are deliberately
not counted — they are the routine loss-recovery path and loss_ppm
already prices them in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:32 +02:00
enricobuehlerandClaude Fable 5 cd6ceb98e3 feat(capture/windows): stall lines say whether the content or the display path went silent
A compose-silence hole used to be blamed on the frame-generation path by
default — mislabeling benign content pauses (menus, loading, game hitches)
as display-path bugs. The corrected discriminator convicts on witnesses:

- Microsoft-Windows-DXGI Present/PresentMPO (ids 42/55) rides the host's
  filtered ETW session — one event per swapchain present, stamped with the
  presenting pid (named in the stall line).
- BltQueueAddEntry/CompleteIndirectPresent (ids 1071/1068) witness frames
  entering/leaving the virtual display's kernel present queue (the modern
  IDD path; anatomy proven on-glass via xperf, 2026-07-30).
- classify() now reads the window counts: presents flowing while the queue
  starved = FRAME-GENERATION (the OS dropped composed frames — the real
  bug class, never yet observed); no presents anywhere = CONTENT-SILENCE
  (benign for the display path); no working witness = UNATTRIBUTED, never
  a guess.
- DWM_TIMING_INFO.cFrame is demoted to an advisory dwm_frames_frozen=
  print: on Win11 it is refresh-synthesized and advances without real
  composes (proven against a kernel trace), so it convicts nothing.
- DxgKrnl legacy Present (id 184) is retired — it never fires on the
  redirected BltQueue path.

Stall lines carry etw_presents=/etw_queue_adds= plus named presenters;
compiled and validated on-glass (a quit-transition stall correctly labeled
CONTENT-SILENCE presents=0).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 23:35:28 +02:00
enricobuehlerandClaude Fable 5 f87c1e6cec fix(encode): gate multi-slice frames on the client's decoder — the 0.17.0 Chromecast crash
ci / docs-site (push) Successful in 1m9s
apple / swift (push) Successful in 1m15s
ci / web (push) Successful in 3m16s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
ci / rust-arm64 (push) Successful in 3m34s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 13s
deb / build-publish-host (push) Successful in 4m20s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 28s
deb / build-publish (push) Successful in 6m17s
arch / build-publish (push) Failing after 7m58s
flatpak / build-publish (push) Successful in 9m6s
ci / rust (push) Successful in 13m37s
release / apple (push) Successful in 16m32s
windows-host / package (push) Successful in 18m21s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 32s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m21s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 3m44s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m24s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m27s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m56s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m54s
apple / screenshots (push) Successful in 20m45s
android / android (push) Successful in 5m0s
Field report: since 0.17.0 a stream to a Chromecast with Google TV 4K
freezes on the first frame and ~80% of the time crashes + reboots the
DEVICE — with both the Punktfunk app and Moonlight, while an Xbox
Series S is fine. Root cause: LN1 Phase 3 (67b79810) defaulted Linux
direct-NVENC to 4 slices per frame for EVERY session. Amlogic HEVC
decoders wedge on multi-slice AUs — exactly why moonlight-android
requests slicesPerFrame=1 for every hardware decoder (4 only for
software slice-threading) — and our RTSP parser never read the request.
The Phase-3 commit recorded the untested leg ("a live Moonlight re-test
joins the standing owed Moonlight item"); this report is that re-test.

The slicing ceiling now belongs to the CLIENT, threaded as open_video's
new max_slices from both planes:
- GameStream: parse x-nv-video[0].videoEncoderSlicesPerFrame into
  StreamConfig and honor it; absent/out-of-range (pre-auth input) => 1.
- punktfunk/1: new Hello cap VIDEO_CAP_MULTI_SLICE (0x80 — the byte's
  LAST free bit; the next cap needs a second byte + ABI bump).
  SessionPlan.max_slices = 32 with the bit, 1 without, applied to every
  encoder the plan opens so rebuilds can't change the wire shape. The
  desktop session client advertises it (FFmpeg/D3D11VA/Vulkan decode
  stacks are fine); Android/Apple stay off until they can decide
  per-decoder like Moonlight does — the cap is embedder-set decoder
  truth, never OR'd in by the shared pump.
- Linux direct-NVENC clamps its Phase-3 default to the ceiling
  (resolve_slices(codec, 4.min(max_slices))) and logs slices/max_slices
  in the caps-probe line; PUNKTFUNK_NVENC_SLICES stays the explicit
  operator override in both directions. Windows keeps its single-slice
  default untouched.

Also repairs the nvenc_cuda #[ignore] hardware tests: d2c46eaf added
open()'s cursor_blend param without updating them, invisible because CI
never compiles tests with the nvenc feature.

Verified on .21 (RTX 5070 Ti): pf-encode + host check/clippy clean with
nvenc; host unit suite 263/0; rtsp announce tests 7/7 incl. the new
slicesPerFrame coverage; on-hardware smokes — default e2e still 4
chunks/frame, NEW single-slice client-ceiling test clamps + disarms
chunked poll with no env involved, env escape unchanged. rustfmt clean.
Windows leg (prepare_display param) is CI-only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 22:23:42 +02:00
enricobuehlerandClaude Opus 5 c04c5be224 docs: USB passthrough is a plugin now, not a recipe you assemble
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 18s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 26s
ci / web (push) Successful in 1m5s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 51s
ci / docs-site (push) Successful in 1m14s
ci / rust-arm64 (push) Successful in 1m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m14s
docker / deploy-docs (push) Successful in 31s
ci / rust (push) Successful in 7m20s
Adds a VirtualHere section to the plugins page — what it is, that both halves of VirtualHere
are yours to install and licence, that the Devices tab writes a name-based rule so it survives
the couch rebooting, and that Diagnostics is where to look when nothing happens.

States the coverage limit up front rather than letting somebody discover it: there is no
VirtualHere server for iOS or tvOS, so those clients cannot pass devices through, and nothing
on our side can change that.

Cuts the automation.md recipe from 75 lines to a pointer. It now leads with "use the plugin"
and keeps only the zero-code two-hook version for people who would rather not install one —
with its trade-offs stated instead of implied: the address is hard-coded so it breaks when the
couch reboots, and an abnormal stream end strands the device on the host. Those two failures
are exactly what the plugin exists to fix, so the reader gets to make an informed choice.

Not build-verified: docs-site does not build standalone in this checkout. Checked by hand that
Callout is in fumadocs' default MDX components with a valid `warn` type, that the JSX balances,
and that the cross-page anchor matches github-slugger of the heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 22:07:14 +02:00
enricobuehlerandClaude Opus 5 ef2bb56251 fix(tray): probe /login without redirects, and open the address the probe checked
ci / docs-site (push) Successful in 1m17s
apple / swift (push) Successful in 1m17s
ci / rust-arm64 (push) Successful in 1m48s
ci / web (push) Successful in 1m56s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 14s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 2m6s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 11s
deb / build-publish (push) Successful in 5m3s
docker / builders-arm64cross (push) Successful in 6s
deb / build-publish-host (push) Successful in 4m14s
docker / deploy-docs (push) Successful in 27s
ci / rust (push) Successful in 6m52s
android / android (push) Successful in 7m11s
arch / build-publish (push) Failing after 7m24s
apple / screenshots (push) Successful in 14m27s
windows-host / package (push) Successful in 18m20s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m10s
Two ways the tray could misreport a healthy console.

The probe hit `/`, which is auth-gated and 302s to /login, and ureq follows
redirects by default — so TLS, `/`, and a full cold Nitro SSR of /login all had
to fit inside one 2s budget, and a console that was merely warming up read as
down. It now probes /login with redirects(0) on the agent: one round trip, and a
302 already counted as up. Neither user of that agent wants redirects; the
summary is a terminal JSON route.

The menu then opened https://localhost:<port> while the poller probed 127.0.0.1.
web-run.cmd binds HOST=0.0.0.0 — IPv4 only — and Windows resolves localhost to
::1 first, so the tray could call the console healthy and hand the browser a URL
that fails. Both are 127.0.0.1 now, which is what the Linux tray already did, so
the menu can no longer disagree with the status printed above it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Opus 5 d9912aa795 fix(windows/update): an update puts the status tray back instead of killing it for good
Every update started from the web console killed the tray permanently. Three
pieces had to line up, and they did: the installer's StopTrays force-kills every
punktfunk-tray.exe (it is one of the files being replaced), its relaunch is a
[Run] entry flagged skipifsilent, and the in-console updater spawns the installer
with /VERYSILENT. So the tray died on every such update and waited for the next
sign-in, because the Run value is a logon trigger. Confirmed on a box whose tray
was absent while its Run key, its exe and a session signed in hours before the
update were all present.

The installer cannot fix this itself. Spawned from the SYSTEM host service, its
`runasoriginaluser` resolves to SYSTEM — so relaunching there would place a
SYSTEM-owned tray in the user's session, where it would hold the per-session
Local\PunktfunkTray mutex and block the real tray at the next sign-in. Strictly
worse than the bug.

The host does it instead, because only the host has the right token. IntentRecord
gains tray_was_running, captured before the installer is spawned (reusing the
conflicting-host scan's process snapshot rather than opening a second one), and
boot reconciliation reads it off the intent before reconcile consumes it and
relaunches through tray::start(). Restored on both terminal outcomes — a
rolled-back install killed the tray just as dead as a successful one — but never
while an apply is still in flight, where the installer may only kill it again.

The field is serde(default), so an intent written by an older host reads as false
and behaves exactly as before; covered by a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Opus 5 c77823d800 feat(windows/tray): punktfunk-host tray start|stop|status, so a dead tray is one command away
The status tray is a per-user, per-session GUI process with no recovery path of
its own: the HKLM Run value only fires at sign-in, and nothing in the product
ever restarts one. Anything that kills a tray — an upgrade's StopTrays, a crash —
therefore left the operator without an icon until they signed out and back in,
with no way to ask for it back.

windows/tray.rs becomes the one place that knows tray lifecycle (find, start,
stop, is-running), so the CLI and post-update reconciliation share an
implementation rather than growing two.

start() cannot simply spawn the exe, because the launch crosses a privilege
boundary in one direction only:

  - from the host service (SYSTEM) the tray must land in the active console
    session under the LOGGED-IN USER's token, which is WTSQueryUserToken +
    CreateProcessAsUserW and needs SE_TCB — SYSTEM-only;
  - from a shell already in the console session that call fails (an administrator
    does not hold SE_TCB either) and a plain spawn is both sufficient and right.

Trying the privileged path and falling back discriminates the two without
inspecting a token, and adds no unsafe to the crate. But a plain fallback from
ssh/RDP would put a tray in a session nobody can see and report success, so on
failure it consults console_session_mismatch() and refuses with an explanation
instead. stop() is graceful first (--quit lets the tray remove its own icon via
NIM_DELETE, as the uninstaller does), then forces any instance in another session.

Verified on Windows: launched from a SYSTEM scheduled task the tray comes up in
the console session owned by the interactive user, not SYSTEM; from an ssh
session (0 -> console 2) it refuses; a second start is a no-op; stop is graceful
and idempotent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Opus 5 3bb30cb2f6 fix(windows/web-console): a bun exit no longer parks the console until the next sign-in
The PunktfunkWeb task registers RestartCount=10/PT1M, but Task Scheduler honours
restart-on-failure only when the action crashes or fails to start — never for a
plain non-zero exit. So any bun exit left the console down until the next boot or
interactive logon, with the host still streaming perfectly next to it. Seen on
glass on a 0.22.3 upgrade: `web setup` started the task one second after the new
service came up, bun exited at once (Last Run Result 0xFFFFFFFF), and the console
sat dead for hours while its box served clients — which reads to an operator as
"the host is gone".

web-run.cmd now supervises bun instead of exiting with it: restart on any exit,
indefinitely for a console that served a while and then died, but give up after
10 CONSECUTIVE fast exits so a genuinely broken install still surfaces as a failed
task. Any run lasting >= 60s resets that counter. It exits 0 rather than
respawning when the payload disappears, so an uninstall does not make it spin.

Uptime is measured with an unambiguous clock rather than parsed out of %TIME%,
which is locale-formatted (12-hour locales append " PM", many others use ',' as
the decimal separator) — the box this was found on prints 18:36:28,39.

stop_web_console() polls until :47992 is really free instead of sleeping a blind
second: both `schtasks /end` and `taskkill /F` are asynchronous, and web_setup
starts the new task immediately afterwards, where bun cannot bind a port the
corpse still holds. It re-ends the TASK halfway through, not just the listener —
with the launcher now supervising, killing bun alone would be undone by its own
restart loop.

The systemd unit had the same shape of hole: Restart=on-failure leaves a console
that exited 0 down. Now Restart=always, which an explicit `systemctl stop` still
overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 20:42:50 +02:00
enricobuehlerandClaude Fable 5 e90c5d5bcd fix(gamepad): the output-report ring absorbs haptics-rate writers (PadShm v2.2)
ci / web (push) Successful in 1m1s
windows-drivers / probe-and-proto (push) Successful in 1m3s
ci / docs-site (push) Successful in 1m16s
windows-drivers / driver-build (push) Successful in 2m15s
ci / rust-arm64 (push) Successful in 3m27s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 8s
deb / build-publish-client-arm64 (push) Successful in 2m21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 9s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 6s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 15s
apple / swift (push) Successful in 4m36s
deb / build-publish-host (push) Successful in 4m2s
docker / builders-arm64cross (push) Successful in 5s
docker / deploy-docs (push) Successful in 29s
arch / build-publish (push) Failing after 6m11s
deb / build-publish (push) Successful in 6m6s
android / android (push) Successful in 8m29s
ci / rust (push) Successful in 10m32s
windows-host / package (push) Successful in 16m18s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 12s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m15s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m42s
apple / screenshots (push) Successful in 20m33s
A field log (2026-07-30) showed a game driving the virtual DualSense's output
endpoint at >2 kHz sustained for tens of seconds. The 8-slot ring — sized on
the assumption that 2 kHz is double any real HID output rate — overflowed
every 4 ms poll, which force-silenced rumble for each storm's whole duration
and flooded the log at ~230 WARN lines/s (96 % of the user's 5000-line
web-console export, evicting the session history it was needed to diagnose).

Three legs, negotiated so every old/new host×driver pairing keeps working:

- pf-driver-proto: the ring grows in place 8 -> 56 slots; PadShm becomes
  exactly one page (4096 B), the hard ceiling that keeps cross-generation
  section views mappable. A new out_ring_len field carries the driver's side
  of the length negotiation. Deliberately NOT a GAMEPAD_PROTO_VERSION bump
  (that fails closed - no pad at all).
- pf-gamepad driver: picks its ring length from the host's out_ring_ver
  stamp (>= 2 + a full-size map -> 56) and echoes it before every ring_head
  bump (now a Release store), so an Acquire-observing drain always reads the
  modulo that indexed the slots it copies.
- host drain: follows the echo (0 = old driver = 8); on genuine overflow it
  now salvages the legacy latest-report slot - the freshest coalesced state -
  instead of total silence, and the per-poll overflow WARN is rate-limited to
  1 line/s per pad with a suppressed count.

Verified on the Windows CI runner (drivers workspace build + clippy -D
warnings against the WDK; 64 pf-inject tests incl. the new negotiation/
salvage/limiter tests; pf-inject clippy -D warnings) and on Linux via the CI
docker image (82 pf-inject tests). DriverVer needs no manual bump - the
installer stamps a strictly-increasing build timestamp per release.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:36:47 +02:00
enricobuehlerandClaude Fable 5 ec675261fc feat(android): the Sony-pad USB grant is asked on connect, not found in Settings
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 21s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
ci / docs-site (push) Successful in 1m4s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 19s
ci / rust-arm64 (push) Successful in 1m21s
ci / web (push) Successful in 1m33s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m6s
docker / builders-arm64cross (push) Successful in 6s
android / android (push) Canceled after 1m55s
ci / rust (push) Canceled after 1m56s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 47s
docker / deploy-docs (push) Canceled after 0s
On-glass feedback: burying the grant in the Controllers screen made the
user go find it. Now MainActivity asks the moment a Sony pad appears — a
fresh attach while the app is open, or the app foregrounding with one
already plugged in — once per attach (a deny doesn't re-nag; the
Controllers card's button stays as the re-ask). Nothing starts on the
grant: an uncaptured pad is an ordinary InputDevice at menu time, so the
grant is simply recorded and the next stream's capture engages silently.
The grant broadcast is shared with the Controllers card so an open card
refreshes from either dialog.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:34:57 +02:00
enricobuehlerandClaude Fable 5 1984ddb942 feat(android): a USB Sony pad is captured — rumble, adaptive triggers, lightbar, gyro
A DualSense on a phone had rumble only where the kernel exposed force
feedback, and adaptive triggers / lightbar / player LEDs nowhere — Android
has no platform API for any of them, and Bluetooth offers no raw path
(L2CAP is LE-only; hidraw is root-sealed — Sony's own Remote Play declares
Android triggers unsupported). Claiming the pad's HID interface over USB is
the one unrooted route, so that is what the client now does.

- HidUsbLink: the device-agnostic half of Sc2UsbLink (claim, multiplexed
  UsbRequest loop, newest-wins write queue, signalled-unplug discipline),
  parameterized by device match / interface filter / keep-alive. Sc2UsbLink
  keeps only its SC2 specifics (Puck interfaces 2..5, lizard refresh).
- GamepadFeedback.PadFeedbackSink: 0xCA rumble + 0xCD Led/PlayerLeds/
  Trigger now route to a capture link that owns the pad BEFORE the
  InputDevice vibrator/lights paths — Trigger stops being log-and-drop.
- DsDevice: the byte-exact inverse of the host's dualsense_proto /
  dualshock4_proto — input report 0x01 parse (buttons/sticks/triggers,
  gyro+accel, both touch points; Edge FN/BACK → wire paddles) and output
  builders (DS5 0x02 valid-flag-selective incl. the 11-byte trigger blocks
  and the lightbar-animation release; DS4 0x05 as composed full-state
  writes). Covered by DsDeviceTest (pure JVM).
- DsCapture: stream-mode capture for DualSense / Edge / DS4 — lazy wire
  slot on the first parsed report, typed mirror (exit chord included),
  touch normalized onto the rich plane + per-report motion, feedback
  rendering with a rumble backstop (a USB pad holds its level, so a
  stalled poll thread self-terminates via a scheduled zero-write) and a
  teardown motor-stop over EP0. The claim releases the pad's InputDevice
  slot itself so the wire index hands over deterministically; uncaptured
  (toggle off / permission denied / Bluetooth) the pad stays on the
  ordinary InputDevice path.
- Rich-input shims: nativeSendPadTouch / nativeSendPadMotion →
  RichInput::Touchpad / Motion — the plane the desktop and Apple clients
  already feed; Android pads gain gyro + touchpad on the virtual pad.
- Settings: "DualSense / DualShock passthrough (USB)" (ds_capture, opt-out
  like the SC2 toggle); Controllers screen card with capture status and a
  front-loaded USB grant so streams start without the permission dialog.

The host needs nothing: the DS5/DS4 backends already consume the typed +
rich planes and already emit every feedback event rendered here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 18:34:57 +02:00
enricobuehlerandClaude Fable 5 696386dee7 fix(host/windows): clippy — SAFETY comments on the WinTrust FFI, explicit truncate(false), sort_by_key
deb / build-publish-client-arm64 (push) Failing after 4s
ci / docs-site (push) Successful in 1m18s
ci / web (push) Successful in 2m12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 56s
deb / build-publish-host (push) Successful in 4m28s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 31s
docker / builders-arm64cross (push) Successful in 11s
ci / rust-arm64 (push) Successful in 5m49s
docker / deploy-docs (push) Successful in 31s
android / android (push) Successful in 6m54s
deb / build-publish (push) Successful in 5m0s
apple / swift (push) Successful in 5m6s
ci / rust (push) Successful in 6m13s
arch / build-publish (push) Failing after 7m37s
windows-host / package (push) Successful in 15m8s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Successful in 41s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 16m40s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m39s
apple / screenshots (push) Successful in 21m28s
windows-host.yml's clippy sees what the Linux gate structurally cannot (cfg(windows)
code); verified with the same clippy line on the runner this time, not just cargo check.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:42:25 +02:00
enricobuehlerandClaude Fable 5 5790a3e334 docs(release): v0.22.3 notes — the Updates card and one-click updating join the installer fixes
ci / web (push) Successful in 2m25s
ci / rust-arm64 (push) Successful in 2m53s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 13s
ci / docs-site (push) Successful in 1m6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 8s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 24s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 33s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / builders-arm64cross (push) Successful in 9s
docker / deploy-docs (push) Successful in 33s
ci / rust (push) Successful in 7m2s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:23:54 +02:00
enricobuehlerandClaude Fable 5 3b1485e2a1 fix(plugins/windows): the runner can read its own bundle, so it actually starts
ci / rust (push) Canceled after 27s
ci / rust-arm64 (push) Canceled after 28s
ci / web (push) Canceled after 28s
ci / docs-site (push) Canceled after 28s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
apple / swift (push) Successful in 4m45s
android / android (push) Successful in 4m58s
deb / build-publish-host (push) Successful in 4m14s
deb / build-publish (push) Successful in 5m51s
deb / build-publish-client-arm64 (push) Successful in 1m19s
arch / build-publish (push) Failing after 6m54s
windows-host / package (push) Failing after 10m59s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
apple / screenshots (push) Canceled after 14m9s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m17s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 14m33s
Enabling the plugin runner did everything right and then nothing happened.
The task went Ready with LastTaskResult 1 within a second of every start, and
the console kept showing it enabled-but-not-running with nothing to explain
why. Task Scheduler discards the action's output, so the reason never
surfaced anywhere:

  error: EPERM reading "C:\Program Files\punktfunk\scripting\runner-cli.js"

This file already knows why. RUNNER_UNIT_DIRS carries the note that bun opens
the files it loads asking for FILE_WRITE_ATTRIBUTES on top of read, so a plain
(RX) grant makes them die with EPERM — found on glass when the plugin and
script directories were fixed. The runner's own entry script was missed. It
lives in {app}\scripting, which carries only Users:(RX), and LocalService
reaches that through Authenticated Users, so bun could never open the one file
it was started to run. The runner has not been able to start since it moved
off SYSTEM, which is a principal that has full control everywhere and so never
met this.

So {app}\scripting now gets the same (OI)(CI)(RX,WA) the unit dirs get, at
enable, revoked at disable like every other grant here. WA moves timestamps
and the read-only bit and cannot touch content, so the three-way split the
module maintains still holds: code read-only, secrets read-only, only
plugin-state writable. A plugin still cannot rewrite the runner.

Measured on glass on .173, same box, same task, only the ACE differing:
without it Ready/lastResult=1 and no runner process; with it Running, a live
runner, and GET /store/runtime answering running:true — which is exactly what
the console reads. Also checked what bun does with the access: nothing. It
opens the entry for write and never writes, and the directory is byte-identical
afterwards.

Windows-only code, so it is rustfmt-clean and type-reviewed but NOT
compile-verified here — the Windows CI job is the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 17:23:16 +02:00
enricobuehlerandClaude Fable 5 4a540bddc8 feat(host/deck): one-click source rebuild — the update.sh run as a transient user unit
ci / rust-arm64 (push) Successful in 1m36s
ci / docs-site (push) Successful in 1m28s
ci / web (push) Successful in 2m25s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 9s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 6s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 1m13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
deb / build-publish-client-arm64 (push) Successful in 2m27s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m47s
deb / build-publish-host (push) Successful in 5m53s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 7s
android / android (push) Successful in 7m2s
docker / builders-arm64cross (push) Successful in 11s
arch / build-publish (push) Failing after 7m33s
ci / rust (push) Successful in 7m35s
docker / deploy-docs (push) Successful in 39s
windows-host / package (push) Failing after 11m1s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
deb / build-publish (push) Successful in 6m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m4s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m21s
apple / swift (push) Successful in 4m58s
apple / screenshots (push) Successful in 21m32s
The U3.1 leg of planning:host-update-from-web-console.md. The Deck's on-device install
gets the Update-now button with no opt-in (user-owned, no root): update.sh --pull runs
under systemd-run --user so the script's own restart of punktfunk-host can't kill it
mid-build (a child in our cgroup would die with us). Outcome without version equality —
which a source rebuild can't promise: a failed build leaves the host alive to report it
(unit watched to failure, log attached); a successful one restarts us, and the new
source_build intent flag makes intent-present-at-boot itself the success signal (the
script only restarts the host after a successful install). The console stops treating
a live long build as a timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:28:40 +02:00
enricobuehlerandClaude Fable 5 51d5f6cb29 feat(host/windows): boot-loop auto-rollback — the supervisor re-runs the cached previous installer
ci / docs-site (push) Successful in 1m11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 6s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
ci / web (push) Successful in 1m58s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 1m8s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 42s
deb / build-publish-client-arm64 (push) Successful in 3m19s
deb / build-publish-host (push) Successful in 6m17s
ci / rust-arm64 (push) Successful in 6m58s
android / android (push) Successful in 4m51s
docker / builders-arm64cross (push) Successful in 12s
windows-host / package (push) Failing after 5m39s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
docker / deploy-docs (push) Successful in 1m58s
arch / build-publish (push) Failing after 8m14s
apple / swift (push) Successful in 4m59s
apple / screenshots (push) Canceled after 0s
deb / build-publish (push) Successful in 11m51s
ci / rust (push) Canceled after 13m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m38s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m11s
The U3.2 leg of planning:host-update-from-web-console.md. The service worker loop —
the one piece that survives an upgrade — detects a fresh update-intent whose target
version is the crash-looping child (≥3 rapid restarts, intent <30 min), then exactly
once: picks the newest cached non-target installer, requires a valid Authenticode
signature, writes a rolled-back result record for the console, deletes the intent,
and spawns the installer silently. No cached installer or a failing signature degrade
to today's backoff loop with a loud log — never a brick, never an installer loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:12:22 +02:00
enricobuehlerandClaude Fable 5 3e21398c16 fix(lock): pf-update rides the 0.22.3 workspace bump
audit / bun-audit (plugin-kit) (push) Successful in 22s
audit / cargo-audit (push) Successful in 37s
audit / bun-audit (sdk) (push) Successful in 21s
audit / pnpm-audit (push) Successful in 11s
audit / docs-site-audit (push) Successful in 22s
ci / web (push) Successful in 1m52s
ci / docs-site (push) Successful in 2m4s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m58s
android / android (push) Successful in 5m38s
audit / license-gate (push) Successful in 5m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m11s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 48s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 4s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 5s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
apple / swift (push) Canceled after 0s
arch / build-publish (push) Failing after 7m37s
apple / screenshots (push) Canceled after 0s
ci / rust (push) Canceled after 6m46s
ci / rust-arm64 (push) Canceled after 6m42s
deb / build-publish (push) Canceled after 4m47s
deb / build-publish-host (push) Canceled after 4m17s
deb / build-publish-client-arm64 (push) Canceled after 2m22s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 29s
docker / deploy-docs (push) Canceled after 0s
audit / bun-audit (web) (push) Successful in 24s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 28s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m3s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 28s
windows-host / package (push) Canceled after 6s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m31s
flatpak / build-publish (push) Successful in 10m8s
release / apple (push) Successful in 26m17s
The U2 branch minted the lock entry at 0.22.2 and the rebase onto the version bump
left it stale — --locked builds fail until it matches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 16:04:17 +02:00
enricobuehlerandClaude Fable 5 239c69fd71 feat(web,docs): Linux one-click in the card — opt-in hint, staged and nothing-newer outcomes
audit / bun-audit (sdk) (push) Successful in 18s
audit / bun-audit (web) (push) Successful in 18s
audit / docs-site-audit (push) Successful in 23s
audit / pnpm-audit (push) Successful in 12s
audit / cargo-audit (push) Successful in 37s
audit / bun-audit (plugin-kit) (push) Successful in 1m14s
arch / build-publish (push) Failing after 1m17s
ci / rust-arm64 (push) Failing after 44s
ci / web (push) Successful in 1m5s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 21s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 6s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 5s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 5s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
ci / docs-site (push) Successful in 2m18s
deb / build-publish-host (push) Failing after 2m15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 53s
deb / build-publish (push) Failing after 3m43s
audit / license-gate (push) Successful in 4m47s
deb / build-publish-client-arm64 (push) Successful in 3m52s
docker / builders-arm64cross (push) Successful in 14s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 2m49s
ci / rust (push) Failing after 3m6s
docker / deploy-docs (push) Successful in 55s
windows-host / package (push) Failing after 6m27s
windows-host / canary-manifest (push) Skipped
windows-host / winget-source (push) Skipped
android / android (push) Successful in 8m21s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
flatpak / build-publish (push) Successful in 4m34s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Failing after 4m19s
release / apple (push) Canceled after 57s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 4m45s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 2m26s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
The apply panel serves staged kinds too; notify mode shows the group-join command when
the helper is installed but not yet enabled; outcomes render 'staged — reboot to
finish' and 'your package source had nothing newer yet' distinctly. Docs: the Linux
opt-in section (why it's a group, what the grant bounds, the pacman stance).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 06a249e49f feat(host/linux): opt-in one-click updates — the pf-update root helper, group-scoped polkit grant, per-PM legs
The U2 leg of planning:host-update-from-web-console.md. A new dep-free root helper
(crates/pf-update) runs the distro package manager against the INSTALLED punktfunk
packages — apt (index refresh scoped to our list when present), dnf, rpm-ostree
(single-transaction re-resolve, reported staged), sysext (the proven signed-feed
updater), pacman only behind the explicit PACMAN_FULL_SYSUPGRADE opt-in — then the
run-the-binary gate, then a root-written result record. Zero attacker-influenceable
parameters end to end: fixed ExecStart oneshot (punktfunk-update.service), polkit rule
scoped to that one unit's start verb for the shipped-EMPTY punktfunk-update group
(joining it is the auditable opt-in; every postinst creates it, none populate it).
The host starts the unit, interprets the record (staged / nothing-newer-yet / changed),
and crosses its own restart on the same intent/reconcile machinery as the Windows leg.
Status now reports staged results and the opt-in hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 bb1f93d90e feat(web,docs): the Update-now flow — password re-entry at the BFF, restart-tolerant progress, same-origin hardening
apply.post.ts intercepts the one proxied route that restarts the host: the console
password is re-verified per apply (login throttle shared, stripped before forwarding) so
a 7-day cookie alone can't do it. New Sec-Fetch-Site same-origin check on every mutating
request (login CSRF included). The card's apply flow: confirm dialog → live-session
force escalation → download/verify/restart progress rendered from the last snapshot
while polls fail (the host and, on Windows, this very server restart mid-flow) →
durable success/failure from last_result. Docs: the one-click section + kill switch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehlerandClaude Fable 5 bb48225414 feat(host/windows): one-click update apply — verified installer download, intent/reconcile, POST /update/apply
The U1 leg of planning:host-update-from-web-console.md. The apply request carries no
version/url/channel (the zero-parameter invariant): the host installs exactly what its
Ed25519-verified manifest announced. Pipeline: staged download (resume + disk preflight)
→ manifest sha256 → Authenticode (valid signature, untrusted root tolerated while the
cert is self-signed; leaf sha256 pinned via the signed manifest, extracted from the SAME
WinVerifyTrust state) → intent record → detached CREATE_BREAKAWAY_FROM_JOB spawn of the
winget-blessed silent flags. The installer kills this process by design; boot-time
reconciliation reports the outcome (update.applied event / a durable failure with the
installer log path) across the restart. Session guard (force to override),
PUNKTFUNK_UPDATE_APPLY=0 kill switch, single-flight via job + fresh-intent detection.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 15:55:27 +02:00
enricobuehler 73e1224345 fix(ci/update): an empty AUTHENTICODE_SHA256 broke the manifest build — jq -R needs one input line
ci / web (push) Successful in 1m5s
ci / docs-site (push) Successful in 2m30s
ci / rust-arm64 (push) Successful in 3m40s
android / android (push) Successful in 4m35s
decky / build-publish (push) Successful in 47s
ci / rust (push) Successful in 6m26s
arch / build-publish (push) Successful in 8m54s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 23s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 12s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Failing after 12m27s
docker / deploy-docs (push) Skipped
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 10s
docker / builders-arm64cross (push) Successful in 11s
apple / swift (push) Successful in 4m45s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m47s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m15s
deb / build-publish-host (push) Successful in 4m45s
deb / build-publish-client-arm64 (push) Successful in 1m25s
apple / screenshots (push) Canceled after 7m43s
deb / build-publish (push) Canceled after 3m20s
printf '%s' hands jq -R no line at all for an empty value; its empty output made
--argjson invalid. Seen on the first live canary-manifest run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit a12c8bc99d41c4f7b8e4c426d560171b2b12dd5e)
2026-07-30 15:23:52 +02:00
enricobuehlerandClaude Fable 5 1c836afc02 chore(release): bump workspace version to 0.22.3
ci / web (push) Successful in 2m4s
ci / docs-site (push) Successful in 2m25s
android-screenshots / screenshots (push) Successful in 2m12s
ci / rust-arm64 (push) Successful in 7m8s
apple / swift (push) Successful in 4m38s
decky / build-publish (push) Successful in 52s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m44s
deb / build-publish-client-arm64 (push) Successful in 1m32s
arch / build-publish (push) Successful in 10m25s
linux-client-screenshots / screenshots (push) Successful in 3m16s
audit / bun-audit (plugin-kit) (push) Successful in 28s
audit / bun-audit (sdk) (push) Successful in 27s
audit / bun-audit (web) (push) Successful in 26s
audit / docs-site-audit (push) Successful in 24s
audit / pnpm-audit (push) Successful in 15s
audit / license-gate (push) Successful in 5m11s
sbom / sbom (push) Successful in 35s
android / android (push) Successful in 7m33s
web-screenshots / screenshots (push) Successful in 4m12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 12s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 41s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m36s
audit / cargo-audit (push) Successful in 39s
docker / deploy-docs (push) Successful in 42s
docker / builders-arm64cross (push) Successful in 20s
flatpak / build-publish (push) Successful in 6m50s
deb / build-publish-host (push) Successful in 5m19s
deb / build-publish (push) Successful in 6m58s
ci / rust (push) Successful in 17m25s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m37s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 3m0s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m3s
apple / screenshots (push) Canceled after 0s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 18m46s
release / apple (push) Successful in 25m45s
windows-host / package (push) Successful in 10m58s
windows-host / winget-source (push) Skipped
windows-host / canary-manifest (push) Failing after 18s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 16m26s
Windows-installer patch release. 0.22.1 and 0.22.2 shipped without the web
console in them at all — confirmed from the tag builds' own logs, where the
console build step is `skipped` on a cache hit and the job is still green —
which also removed the only code that stopped bun before the installer
replaced it, hence the "DeleteFile failed; code 5" dialog. Both fixed here,
along with updates no longer disabling an operator's plugin runner.

Lock touched for the 30 workspace members only. `base64` stays at 0.22.1 and
the eight gtk-rs crates stay at 0.22.0 — they share our version space and a
blanket sed would move them to versions that do not exist. Diff is
versions-only, 30 insertions and 30 deletions; `cargo metadata --locked`
resolves; `cargo fmt --all --check` clean in both the main and the
packaging/windows/drivers workspaces.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 beefeaf9d7 fix(ci/windows-host): refuse to pack an installer that is missing a payload
Every payload the job bundles is now asserted before packing: the console, the
bun runtime, the plugin runner, the FFmpeg DLLs and VB-CABLE. Each is optional
to pack-host-installer.ps1 — right for a local debug pack, and the reason
0.22.1 and 0.22.2 shipped with no web console: one unset variable omitted it
behind a single line of log and the build stayed green.

CI knows it bundles all five, so a missing input belongs here as a failure
rather than downstream as a quietly smaller installer. FFmpeg is the one that
would hurt most and was silent too: an amf-qsv host link-imports avcodec, so
an installer missing those DLLs ships a host that cannot start at all, and
FFMPEG_DIR is a fallback to a provisioned path that nothing verified.

The shape is borrowed from the packer's own VB-CABLE check, which already
throws on a supplied-but-empty dir "instead of silently shipping an installer
without the virtual mic - exactly the field regression this bundling fixes".
Same lesson, applied to the rest.

Both paths were exercised on the Windows runner: all five unset fails with
exit 1 naming each one, all five present passes with exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 5b6fe7882a fix(ci/windows-host): a cached console no longer ships an installer without one
WEB_OUTPUT_DIR was exported on the last line of "Build + smoke-boot web
console", a step that is skipped on a cache hit. So the second and every
later build with an unchanged web/ and sdk/ left it unset — and
pack-host-installer.ps1 reads an unset WEB_OUTPUT_DIR as "don't bundle the
console" and says so in one line of log before carrying on happily.

The installers that fell out of that have no {app}\web at all: no
web-run.cmd, so `web setup` bails with "web launcher missing" before it
registers anything, so there is no PunktfunkWeb task, no console, and no
firewall rule for 47992. A user is left with a working host, a tray that says
"Open web console (not responding)" for ever, and nothing to reinstall their
way out of, because every rebuild reproduces it.

It also explains the bun.exe lock reported separately. bun.exe ships under
WithWeb OR WithScripting, but the pre-copy stop was #ifdef WithWeb — so in a
console-less installer bun still shipped while the only code that stopped bun
was compiled out, and replacing a running bun.exe is the "DeleteFile failed;
code 5" modal.

Exporting the variable from its own unconditional step fixes it. The throw
alongside is the actual lesson: a missing web\.output now fails the job
instead of silently redefining what the installer is.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 c64ada5649 fix(windows/installer): an update stops every bun before it replaces bun.exe
Updating to 0.22.1 could die on a modal: "C:\Program Files\punktfunk\bun\
bun.exe — DeleteFile failed; code 5. Access denied." Windows will not delete a
running executable, so that message means a bun was still alive when the copy
reached it, and the installer had no way to recover.

Two things run that bun, and neither is a child of the host service, so
stopping the service — which the installer does correctly wait for — never
touched either. Both are Task Scheduler tasks: PunktfunkWeb (SYSTEM, the
console) and PunktfunkScripting (LocalService, the plugin runner). The only
pre-copy stop we had looked for exactly one of them, and found processes by
task name or by who was listening on 47992/3000. The plugin runner matched
neither clause: different task, and it listens on no port at all. So anyone
who had run `plugins enable` held bun.exe mapped through the whole install and
failed this way on every single update.

The web console could lose the race too. Stop-ScheduledTask returns once
termination has been *requested* and Stop-Process is TerminateProcess, so the
old code went straight from asking to copying; the Rust twin of that routine
has always ended with a deliberate one-second settle. And neither task was
disabled for the duration, while both carry restart-on-failure (web ten times
a minute apart, scripting 999) and the web task also has a logon trigger — so
a force-kill invited a respawn into the middle of a copy that takes well over
a minute at lzma2/max.

StopBunRuntimes now disables both tasks before stopping them, kills any bun
whose image lives under the install dir — by path, so a developer's own bun
survives — keeps the old port sweep for pre-bun installs that ran node on
3000, and then waits until both are actually gone rather than assuming.
bun.exe also gains restartreplace, so if some bun still escapes all of that,
the file lands on the next restart instead of dead-ending the install.

Disabling a task is not free, though: unlike a stopped one it does not come
back at the next boot, so an install that aborts anywhere after that point
would take the console down for good. Hence two restores — a [Run] entry for
the normal flow, and DeinitializeSetup, which Inno calls even when the user
cancels. Both re-enable only what was enabled before the copy, and enabling an
enabled task does nothing, so the pair is idempotent.

Putting things back also turned up a second bug worth naming: the scripting
entry re-registers its task and then unconditionally disables it, which is
right on a first install and has silently switched an operator's plugin runner
off on every upgrade since. The restore runs after both re-registrations and
honours the prior state, so a fresh install still leaves the opt-in runner off.

The Pascal and the PowerShell were both compiled and run on the Windows CI
runner (ISCC, plus the query halves of each command) — but the install path
itself is not yet exercised on glass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:36:46 +02:00
enricobuehlerandClaude Fable 5 b275e6d34c feat(web,docs): the Updates card — version, channel, install kind, and the exact update command
android / android (push) Canceled after 1m54s
apple / swift (push) Canceled after 0s
apple / screenshots (push) Canceled after 0s
ci / docs-site (push) Successful in 1m15s
arch / build-publish (push) Canceled after 1m50s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
ci / rust (push) Canceled after 59s
ci / rust-arm64 (push) Canceled after 2m31s
ci / web (push) Canceled after 0s
deb / build-publish (push) Canceled after 18s
deb / build-publish-host (push) Canceled after 19s
deb / build-publish-client-arm64 (push) Canceled after 12s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 1m15s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 36s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 2m28s
windows-host / package (push) Canceled after 0s
windows-host / canary-manifest (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
decky / build-publish (push) Successful in 50s
Notify-only (U0): polls /update/status (which keeps the host's manifest cache
warm), Check-now with the 30s limit surfaced, release-notes link, stale-feed and
check-disabled states, copyable per-install-kind command. en+de strings; docs-site
'Updating the Host' page wired into the install section.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:34:47 +02:00
enricobuehlerandClaude Fable 5 c4c4f217bf ci(update): build+sign+publish the update manifest — stable at announce, canary after the canary installer
bash+openssl signer (raw-64-byte ed25519 over exact bytes, base64 .sig — the
plugin-index format) with the pinned-key cross-check, manifest-then-sig upload
order, and a live-feed self-verify. announce.yml re-hashes the installer against
its sidecar and fail-closes without UPDATE_MANIFEST_KEY; pre-release tags never
enter the stable feed. windows-host.yml grows a Linux canary-manifest job.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:34:47 +02:00
enricobuehlerandClaude Fable 5 cc01562631 feat(host): update check — signed per-channel manifest, install-kind detection, /api/v1/update surface
The U0 leg of planning:host-update-from-web-console.md: a signed update manifest
(Ed25519, keys pinned in the binary via the plugin-store verify path, serial floor
persisted against rollback, channel-bound, 45-day stale hint) fetched lazily behind
GET /update/status + rate-limited POST /update/check, admin lane only (plugin lane
whole-prefix denied, absent from the cert allowlist). Install kind + channel come
from root-owned facts; deb/rpm/pacman builds now stamp /usr/share/punktfunk/install-kind.
Emits update.available once per discovered version.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 14:34:46 +02:00
enricobuehlerandClaude Opus 5 940bd0b7ec feat(core/abr): the startup capacity probe's target is configurable
ci / docs-site (push) Successful in 1m20s
ci / web (push) Successful in 1m31s
ci / rust-arm64 (push) Successful in 1m44s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 14s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 15s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 13s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 30s
apple / swift (push) Successful in 4m43s
android / android (push) Successful in 6m18s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
deb / build-publish-host (push) Successful in 6m14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
deb / build-publish (push) Successful in 5m16s
docker / builders-arm64cross (push) Successful in 12s
docker / deploy-docs (push) Successful in 39s
arch / build-publish (push) Successful in 9m18s
apple / screenshots (push) Canceled after 4m51s
ci / rust (push) Successful in 9m43s
deb / build-publish-client-arm64 (push) Canceled after 8m52s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 8m7s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 8m5s
windows-host / package (push) Canceled after 10m19s
windows-host / winget-source (push) Canceled after 0s
flatpak / build-publish (push) Successful in 7m26s
release / apple (push) Canceled after 9m48s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 0s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 3m23s
The Automatic startup probe bursts at a fixed 2 Gbps two seconds into every
session, deliberately far above any plausible link so the burst measures the
link rather than itself. On links the burst DISTURBS, that backfires: on an
LG G5 (webOS 10.3), three back-to-back connects to the same Gamescope host
split two ways — the two where the probe finished in ~1-2 s had video within
2-4 s, while the one that hit the 6 s timeout showed no video for 14 s. Even
a "successful" probe on that link reported send_dropped=20211. The webOS
client already caps its own speed test at 320 Mbps because an unbounded
firehose starves a 2-3 core TV; core then bursts the same hardware at 2 Gbps.

PUNKTFUNK_ABR_PROBE_KBPS now sets that target, so an embedder that caps its
own speed test can cap ours to match. Unset, zero, or unparseable keeps the
2 Gbps default, so every existing session behaves exactly as before. The
existing opt-out (PUNKTFUNK_ABR_PROBE=0) is no substitute: it leaves the
climb ceiling pinned at the negotiated ~20 Mbps.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 14:24:57 +02:00
enricobuehlerandClaude Fable 5 77517bbe21 style(web): let biome format the message catalogues and the Logs story
ci / web (push) Successful in 1m16s
ci / rust-arm64 (push) Successful in 1m33s
ci / docs-site (push) Successful in 1m37s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 22s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 18s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 8s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 27s
deb / build-publish-client-arm64 (push) Successful in 1m57s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m5s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 41s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 13s
docker / deploy-docs (push) Successful in 37s
deb / build-publish-host (push) Successful in 4m29s
docker / builders-arm64cross (push) Successful in 9s
deb / build-publish (push) Successful in 5m38s
ci / rust (push) Successful in 6m6s
arch / build-publish (push) Successful in 9m36s
windows-host / package (push) Successful in 20m26s
windows-host / winget-source (push) Skipped
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 26m42s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 26m39s
Cosmetic only, and kept out of the feature commit it rode in on so neither
has to be read through the other. `biome check --write` reindents both
message catalogues from two spaces to tabs (877 lines each, no key, order,
or value touched — verified by comparing the parsed objects) and rewraps the
Logs story fixtures onto one argument per line with trailing commas.

Worth knowing for next time: the catalogues are also written by inlang, which
formats with two spaces, so an edit made through that tooling will pull them
back. Nothing depends on either shape — `bun run codegen` compiles 440
messages for both locales either way.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:28:37 +02:00
enricobuehlerandClaude Fable 5 8d6241efae feat(web): the logs page can hand a log off — as a file, or to the share sheet
The Logs page could only be read in place. Getting a host log into a bug
report meant selecting a screenful of monospace text and hoping the scroll
container gave up the rest.

Two controls in the toolbar now do it properly. Download writes a .log file
named for the moment it was taken; the second button hands the same text to
the OS share sheet where there is one (phones, iPads), and copies it to the
clipboard everywhere else — which is why it is probed at runtime rather than
guessed, and why the button is absent on the one combination where neither
exists (plain HTTP, no Web Share).

Both export what the filters currently match, not the rendered tail: the
1000-row cap is a DOM budget and has nothing to say about how long a file
may be. Lines carry the full date and UTC offset, since a bare wall-clock
time stops meaning anything the moment the file leaves the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:07:15 +02:00
enricobuehlerandClaude Opus 5 384a0adc83 chore(release): bump workspace version to 0.22.2
android-screenshots / screenshots (push) Successful in 1m32s
audit / cargo-audit (push) Successful in 38s
audit / bun-audit (plugin-kit) (push) Successful in 20s
audit / bun-audit (sdk) (push) Successful in 15s
audit / bun-audit (web) (push) Successful in 15s
audit / docs-site-audit (push) Successful in 14s
audit / pnpm-audit (push) Successful in 16s
android / android (push) Successful in 5m17s
apple / swift (push) Successful in 5m7s
audit / license-gate (push) Successful in 4m33s
decky / build-publish (push) Successful in 48s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 14s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 51s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 1m3s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 24s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 20s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 35s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 35s
sbom / sbom (push) Successful in 1m17s
linux-client-screenshots / screenshots (push) Successful in 3m43s
docker / deploy-docs (push) Successful in 19s
docker / builders-arm64cross (push) Successful in 13s
web-screenshots / screenshots (push) Successful in 4m25s
ci / web (push) Successful in 1m6s
ci / rust-arm64 (push) Successful in 3m51s
flatpak / build-publish (push) Successful in 5m18s
ci / docs-site (push) Successful in 1m48s
deb / build-publish (push) Successful in 4m0s
deb / build-publish-client-arm64 (push) Successful in 1m45s
arch / build-publish (push) Successful in 8m9s
ci / rust (push) Successful in 8m20s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m50s
deb / build-publish-host (push) Successful in 5m8s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 4m1s
windows-host / package (push) Successful in 11m9s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m41s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 3m1s
release / apple (push) Successful in 26m25s
apple / screenshots (push) Successful in 20m53s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 20m21s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 19m39s
Patch release: 0.22.0 and 0.22.1 gave every default-configured Windows host a
controller no game could see. The pf-dualsense -> pf-gamepad package rename also
renamed a HARDWARE id, so PnP matched none of our models, fell through to the
devnode's synthesized USB ids and let Microsoft's inbox input.inf win — HidUsb
cannot start on a software-enumerated devnode, and without a start there is no
device interface to answer a channel proof. This cut carries that one-line
restore, the [Models]-vs-host guard test, and the Punktfunk display-name rebrand
across the Windows devices and firewall rules.

Windows hosts only; clients and Linux hosts are untouched.

Versions-only lock diff, hand-applied: the 30 workspace-member entries move
0.22.1 -> 0.22.2. Two sets of third-party crates deliberately share our version
space and are untouched — `base64` at 0.22.1, and the eight gtk-rs crates at
0.22.0 (cairo/gdk-pixbuf/gio/graphene/pango); a blanket sed would corrupt both,
so the bump matches on member name only. `cargo metadata --locked` exits 0 on
the pinned toolchain. Release notes in docs/releases/v0.22.2.md seed the release
body per the Model-1 flow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:36:11 +02:00
enricobuehlerandClaude Opus 5 553676282a feat(windows): the brand reads Punktfunk on every device Windows shows
deb / build-publish-client-arm64 (push) Failing after 4s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 13s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 11s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 11s
ci / web (push) Successful in 1m53s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 13s
ci / docs-site (push) Successful in 2m3s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 15s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 30s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m24s
windows-drivers / driver-build (push) Successful in 2m30s
docker / builders-arm64cross (push) Successful in 6s
docker / deploy-docs (push) Successful in 29s
android / android (push) Successful in 5m7s
apple / swift (push) Canceled after 4m50s
apple / screenshots (push) Canceled after 0s
deb / build-publish (push) Successful in 4m42s
arch / build-publish (push) Canceled after 5m25s
ci / rust (push) Canceled after 5m26s
ci / rust-arm64 (push) Canceled after 5m31s
deb / build-publish-host (push) Canceled after 5m19s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 3m30s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 3m26s
windows-host / package (push) Canceled after 2m29s
windows-host / winget-source (push) Canceled after 0s
windows-drivers / probe-and-proto (push) Successful in 37s
Device Manager, the firewall list and the monitor name all said "punktfunk". The brand
is Punktfunk.

Renamed: the [Strings] blocks of all four driver INFs (device descriptions, install
disks, provider, manufacturer), the `description` on every SwDeviceProfile the host
creates, pf-mouse's HID manufacturer + product strings, pf-vdisplay's IddCx endpoint
friendly + manufacturer names, the EDID 0xFC display-name descriptor — so Windows now
shows `Generic Monitor (Punktfunk)` — and the netsh firewall rule names.

The EDID edit is a single byte (0x70 -> 0x50) and needs no hand-patched checksum:
Edid::generate_with already recomputes both block checksums after patching the serial.

Deliberately left lowercase, because these are IDENTITIES rather than display names and
renaming them would orphan installed state:

  * the SwDeviceCreate enumerator `w!("punktfunk")` — it IS the SWD\PUNKTFUNK\... path
    every pad instance id is built from
  * pf-paths' `join("punktfunk")` — C:\ProgramData\punktfunk
  * the CN=punktfunk-driver cert subject, which purge_driver_certs and both driver build
    scripts match by string
  * install.rs' `lo.contains("punktfunk virtual display")` probes, whose haystack is
    to_ascii_lowercase()d, so they already match the capitalised name

Nothing is orphaned by the renames that DID happen either: netsh rule names,
Get-NetFirewallRule -DisplayName and PowerShell's -match are all case-insensitive, so
the firewall delete paths and reset-pf-vdisplay.ps1's -AdapterName / -GhostMatch
defaults still reap what every release up to 0.22.1 created.

Cosmetic, with two consequences worth knowing: it takes a driver rebuild + re-sign to
appear at all, and an existing devnode keeps its cached FriendlyName until it is
recreated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:30:46 +02:00
enricobuehlerandClaude Opus 5 02e9cc4691 fix(host/windows): a virtual DualSense binds our driver again, not Microsoft's
0.22.0 and 0.22.1 hand every default-configured Windows host a controller no game can
see. `GamepadPref::Auto` resolves to DualSense, so this is the pad almost everyone
gets — which is why it reads as "controllers are broken" rather than as one identity
being broken.

The pf-dualsense -> pf-gamepad PACKAGE rename (560e663a) swore the four hardware ids
were untouched, and then renamed one: `WinDsIdentity::dualsense()` started advertising
`pf_gamepad`, a hardware id no INF of ours declares. pf_gamepad.inx still binds
root\pf_dualsense / pf_dualsense / pf_dualshock4 / pf_dualsenseedge / pf_steamdeck,
deliberately — they are the binding contract with every already-installed system.

PnP therefore matched none of our models and fell through to the USB ids the same
devnode synthesizes for the DualSense identity (USB\VID_054C&PID_0CE6, USB\Class_03),
where Microsoft's inbox input.inf wins on signature. HidUsb then bound a
software-enumerated devnode with no USB port behind it and could not start:
CM_PROB_FAILED_START. No start means hidclass never enumerates the collection PDO, so
there is no device interface, so the devnode cannot answer a channel proof, so the v3
delivery gate correctly refuses to hand over the DATA section. Every layer did its
job; the hardware id was wrong.

Measured on .173 against a clean 0.22.1 install — all four identities served by the
one pf_gamepad.inf package:

  DualSense   pf_gamepad        -> input.inf / HidUsb     FAILED_START
  DualShock4  pf_dualshock4     -> oem74.inf / MsHidUmdf  attached
  Edge        pf_dualsenseedge  -> oem74.inf / MsHidUmdf  attached
  Mouse       pf_mouse          -> oem75.inf / MsHidUmdf  attached

and `devgen /add /hardwareid "root\pf_dualsense"` binds oem74.inf, MsHidUmdf,
CM_PROB_NONE, 'punktfunk Virtual DualSense' — the value restored here.

hwid_matches_inf parses pf_gamepad.inx's [Models] and asserts every hardware id the
host puts on a pad devnode is declared there, with a vacuity assert on the parse so a
shape change fails loudly instead of passing empty. DS4_HWID / DECK_HWID exist so the
test pins the same constants the create paths use. This is the guard the rename needed:
the ids have to outlive any future package rename.

The test is cfg(windows) and has NOT been compiled or run here (no Windows toolchain on
the authoring box) — it needs a Windows leg to go green.

Reported twice on 2026-07-30, a GameSir G8+ and a DualSense, both via Android. The host
log names it exactly: `driver=pf_gamepad ... PnP problem code 10` with
`store=driver package present in the driver store`, i.e. not stale drivers and not a
failed install.

Ship this THROUGH THE INSTALLER. install.rs notes that re-creating a SwDevice with a
known instance id revives the existing devnode with its previously-bound driver and
never re-ranks against the store. Instance ids do not change here, so a box already
holding a PF_PAD_0 phantom bound to input.inf would revive input.inf even with the
right hardware id. `driver install --gamepad` sweeps the phantoms; a bare host-binary
swap does not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 12:30:46 +02:00
enricobuehlerandClaude Fable 5 152aac291e chore(release): bump workspace version to 0.22.1
android-screenshots / screenshots (push) Successful in 1m25s
audit / cargo-audit (push) Successful in 31s
audit / bun-audit (plugin-kit) (push) Successful in 12s
audit / bun-audit (sdk) (push) Successful in 13s
audit / bun-audit (web) (push) Successful in 12s
android / android (push) Successful in 5m37s
audit / pnpm-audit (push) Successful in 7s
audit / docs-site-audit (push) Successful in 13s
decky / build-publish (push) Successful in 23s
ci / rust-arm64 (push) Successful in 1m48s
ci / web (push) Successful in 1m4s
audit / license-gate (push) Successful in 5m21s
ci / docs-site (push) Successful in 2m57s
apple / swift (push) Successful in 4m36s
linux-client-screenshots / screenshots (push) Successful in 9m10s
ci / rust (push) Successful in 12m55s
sbom / sbom (push) Successful in 58s
deb / build-publish-client-arm64 (push) Successful in 5m47s
deb / build-publish-host (push) Successful in 5m38s
web-screenshots / screenshots (push) Successful in 5m54s
docker / builders-arm64cross (push) Successful in 6s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 25s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 57s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 7s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m46s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 28s
docker / deploy-docs (push) Successful in 15s
deb / build-publish (push) Successful in 12m58s
arch / build-publish (push) Successful in 7m25s
windows-host / package (push) Successful in 10m27s
windows-host / winget-source (push) Skipped
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m18s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m45s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 2m50s
flatpak / build-publish (push) Successful in 5m3s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 3m55s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 15m56s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 21m46s
release / apple (push) Successful in 25m28s
apple / screenshots (push) Canceled after 20m3s
Patch release: 0.22.0 shipped the wrong program as punktfunk-session (the
b0ea1e6b clobber), which broke connecting on the Windows and Linux clients.
This cut carries the restore, the CLI's self-documentation, and the CI gates
that run the shipped binaries.

Versions-only lock diff, hand-applied: the 30 workspace-member entries move
0.22.0 -> 0.22.1; the eight third-party gtk-rs crates that also sit at 0.22.0
(cairo/gdk-pixbuf/gio/graphene/pango) are untouched. `cargo metadata --locked`
exits 0 on the pinned toolchain. Release notes in docs/releases/v0.22.1.md
seed the release body per the Model-1 flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 11:00:29 +02:00
enricobuehlerandClaude Fable 5 a48321ee27 test(client): CI runs the binaries it ships
ci / web (push) Canceled after 43s
audit / bun-audit (plugin-kit) (push) Successful in 17s
audit / pnpm-audit (push) Successful in 9s
audit / cargo-audit (push) Successful in 34s
audit / bun-audit (sdk) (push) Successful in 1m8s
apple / swift (push) Successful in 1m17s
audit / bun-audit (web) (push) Successful in 1m24s
audit / docs-site-audit (push) Successful in 1m24s
android / android (push) Canceled after 2m9s
apple / screenshots (push) Canceled after 0s
arch / build-publish (push) Canceled after 2m12s
audit / license-gate (push) Canceled after 1m39s
ci / rust (push) Canceled after 1m39s
ci / rust-arm64 (push) Canceled after 1m4s
ci / docs-site (push) Canceled after 43s
deb / build-publish (push) Canceled after 0s
deb / build-publish-host (push) Canceled after 0s
deb / build-publish-client-arm64 (push) Canceled after 0s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Canceled after 0s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Canceled after 0s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Canceled after 0s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Canceled after 0s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Canceled after 0s
docker / builders-arm64cross (push) Canceled after 0s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Canceled after 0s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Canceled after 0s
docker / deploy-docs (push) Canceled after 0s
flatpak / build-publish (push) Canceled after 4s
release / apple (push) Canceled after 1m5s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 5s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 5s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Canceled after 2m6s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Canceled after 0s
windows / build (aarch64-pc-windows-msvc) (push) Canceled after 0s
windows / build (x86_64-pc-windows-msvc) (push) Canceled after 0s
The 0.22.0 clobber proved a gap no build gate covers: a wrong program wearing the
right binary name compiles green. `cargo build -p punktfunk-client-session` happily
shipped the GTK shell's three-line Windows stub as punktfunk-session, because nothing
between commit and release ever EXECUTED the result.

Two integration tests close the class, and they run under gates that already exist
(ci.yml's workspace test on Linux, windows.yml's test step on Windows):

  - contract_smoke spawns the real punktfunk-session against a refusing port and
    asserts the stdout contract answers — whatever fails first on the machine
    (presenter init headless, the dial elsewhere), the binary must SAY so in a
    contract line. Proven non-vacuous by planting the 0.22.0 stub and watching it
    fail, then pass again on the real main.rs.
  - cli_smoke runs the real punktfunk over its help surface (stdout, exit 0) and an
    unknown verb (stderr, exit 5) — store-free and network-free, safe on any runner.

windows.yml now gates punktfunk-cli in all four steps (build, clippy, fmt, test): the
MSIX has shipped its `punktfunk.exe` alias since bf981027, but only the release
workflow ever compiled it — a PR could break the CLI and find out on tag day.

The session README also stops selling `--pair` as the enrolment route (deprecated by
bf981027, `punktfunk pair` is the door) and says out loud what this binary is: a
deliberately dumb renderer the GTK shell, the WinUI shell and the CLI all call into
through one brain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:58:44 +02:00
enricobuehlerandClaude Fable 5 8e396b8391 feat(cli): every command explains itself
`punktfunk help` was one usage blob, and `punktfunk pair --help` was worse than
nothing: the flag fell through to the verb, which read "--help" as its subject,
printed a terse usage line to stderr and exited 5. For the command that is about to
be the documented door for scripts and plugins (Playnite shells to `library --json`),
"self-documenting" has to actually hold.

Now `punktfunk help <command>` — and `--help`/`-h` after any verb, caught BEFORE
dispatch so no verb can mistake the flag for its subject — prints that command's own
page: flags, what lands on stdout vs stderr, and which exit code means what, which is
the part a script author actually needs. Help goes to stdout and exits 0; an unknown
topic refuses with 5. A unit test walks USAGE and asserts every advertised verb has a
help page that leads with its own invocation, so the overview and the pages cannot
drift apart, and an integration test runs the REAL binary over both spellings.

`reachable` also stops scolding: probing an unsaved address is that verb's documented
use, but it resolved through the saved-host path first, whose "pair it first" advice
printed before the probe ran. It resolves quietly now — same lookup, no lecture.

Verified on the Windows CI runner (clippy -D warnings, fmt, 8/8 tests, and the built
punktfunk.exe by hand: overview, per-verb pages, quiet `reachable` exit 2) and in the
Linux CI image (same gates, 8/8). One field note from the hand run: the exe needs the
FFmpeg DLLs beside it or on PATH — true of the session binary already, and the MSIX
ships them next to both, so packaging is unaffected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:58:44 +02:00
enricobuehlerandClaude Opus 5 ff01db67ff fix(client): punktfunk-session is the session binary again — 0.22.0 shipped the shell's stub
Connecting from the 0.22.0 Windows client bounces straight back to the host list, on
every host. The shell is fine; the binary it spawns is not.

b0ea1e6b was a `clients/linux` change that also dropped a verbatim copy of the GTK
shell into `clients/session/src/` — app.rs, cli.rs, the four ui_*.rs, shortcuts.rs,
spawn.rs — and, fatally, OVERWROTE `clients/session/src/main.rs` with the shell's.
That file is `[[bin]] punktfunk-session`. So the binary every shell execs for a stream
stopped being the Vulkan session and became the GTK shell:

  - Windows: the shell's `#[cfg(not(target_os = "linux"))]` arm — print
    "punktfunk-client is Linux-only" to stderr, `exit(2)`. It never writes a single
    line of the stdout contract, so the shell sees EOF with no `ready`, no `error`,
    no `ended`, and returns to the host list with a BLANK banner. Exactly the report.
  - Linux: `app::run()` sees `--connect` in argv and calls `exec_session()`, which
    execs `punktfunk-session` — itself. A stream is an exec loop.

CI could not catch it. The Windows leg of the clobbered file is a three-line stub that
compiles perfectly; `cargo build -p punktfunk-client-session` stayed green while
building the wrong program. Only running it fails, and nothing runs it.

61bdf11e then read the 327 E0433s on the Linux leg as a missing-manifest bug and
declared gtk4/libadwaita/relm4 on this crate. That fixed the build of the wrong file
and cemented the clobber. Both go: the copy is deleted, `main.rs` is restored from
bf981027 (the last commit that touched the real one), and the manifest loses the GTK
block plus the gresource build-dep and `data/` that arrived with 944c03dd to feed it.
serde_json returns to `optional`/`ui`, which is what it always was — the reason
`--no-default-features` didn't compile was cli.rs, and cli.rs was never ours.

Also closes the hole that made this silent. `SpawnEvent::Exited` now carries the
child's exit code, and a child that exits nonzero having said NOTHING gets a banner
naming that code instead of an empty string. Code 0 (stream window closed) and -1
(our own Disconnect/Cancel kill) stay silent, as before. A wrong or crashing session
binary is now a legible failure rather than a connect that quietly does nothing.

Verified. Windows x86_64 on the CI runner: check, clippy -D warnings, rustfmt, tests
(5 passed, incl. the new one) all green, and the rebuilt punktfunk-session.exe answers
`--connect` with `{"error":"presenter: SDL window: …"}` — the real contract — where
0.22.0's answered with nothing. Linux amd64 in the CI image: check with default AND
--no-default-features, clippy -D warnings, rustfmt, tests green, and the built binary
emits `{"error":"presenter: SDL video: …"}` + exit 4 instead of exec-looping.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:58:44 +02:00
enricobuehlerandClaude Fable 5 5c0cb84fda fix(ci/windows-host): drop the drivers-target cache — act rotates the workspace path
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 12s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 14s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 16s
windows-host / package (push) Canceled after 0s
windows-host / winget-source (push) Canceled after 0s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 13s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 21s
ci / rust-arm64 (push) Successful in 1m34s
ci / web (push) Successful in 1m45s
docker / deploy-docs (push) Successful in 37s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 1m29s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 1m30s
ci / docs-site (push) Successful in 1m57s
docker / builders-arm64cross (push) Successful in 9s
ci / rust (push) Successful in 3m51s
I added it yesterday on the theory that cargo's fingerprints would sort out
a restored in-tree target/. They can't: act gives each run a different
absolute workspace (~/.cache/act/<hash>/hostexecutor), and a target dir
restored under a new path carries state pointing at the old one. Measured
today — pf-umdf-util died with 14 x 'unable to create file lock (os error
3)' and failed the job. ~1 min of rebuild is the right price, and it's the
same rotation that made the other Windows jobs use a fixed C:\t.

The web console cache stays: it restored cleanly and skipped its ~2.5 min
build+smoke, taking the job 13.6 -> 8.5 min in the same measurement.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:46:47 +02:00
enricobuehlerandClaude Fable 5 ba3add85b5 fix(ci/windows): install zstd, or every cache save dies on a missing gzip
apple / swift (push) Successful in 1m19s
ci / docs-site (push) Successful in 1m16s
ci / rust (push) Successful in 4m35s
android / android (push) Successful in 5m11s
ci / web (push) Successful in 5m24s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 11s
deb / build-publish-host (push) Successful in 4m26s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 11s
deb / build-publish-client-arm64 (push) Successful in 1m20s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 1m11s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m34s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 2m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
decky / build-publish (push) Successful in 59s
ci / rust-arm64 (push) Successful in 8m59s
docker / deploy-docs (push) Failing after 21s
docker / builders-arm64cross (push) Successful in 1m55s
apple / screenshots (push) Successful in 10m59s
arch / build-publish (push) Successful in 12m32s
deb / build-publish (push) Successful in 14m53s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 20m10s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 19m53s
The new windows-host cache steps reported '::warning::Failed to save' and
nothing ever seeded. actions/cache probes for zstd, doesn't find it, falls
back to gzip — and Git's GNU tar then shells out to a gzip that is not on
the runner daemon's PATH: 'Child returned status 127', 'cache.tgz: Cannot
write: Broken pipe', tar exit 2. Since save failures are warnings, the job
stayed green while caching silently did nothing.

zstd (+ a staged gzip.exe as insurance) now installs into its own directory
— never Git's usr\bin on PATH, which would shadow Windows' find/sort/echo.
Applied live to the runner and added to the machine PATH; this step keeps a
rebuilt runner honest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 10:16:43 +02:00
319 changed files with 27490 additions and 11668 deletions
+31
View File
@@ -31,6 +31,37 @@ jobs:
steps:
- uses: actions/checkout@v4
# Publish the SIGNED stable update manifest — the moment every host's update check learns
# about this release (planning: host-update-from-web-console.md §3.3). Deliberately here in
# announce, not on the tag: the manual "fleet is green, go" gate doubles as the gate for the
# fleet-wide "update available". Fails the announce loudly if the key is missing (fail-closed)
# or the installer's live bytes don't match their .sha256 sidecar. Pre-release tags are
# ALWAYS skipped — an -rc must never enter the stable feed, even with allow_prerelease.
- name: Publish the stable update manifest
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
run: |
set -euo pipefail
TAG="${{ inputs.tag }}"
case "$TAG" in
*-*) echo "pre-release tag $TAG — not publishing to the stable update feed"; exit 0 ;;
esac
VER="${TAG#v}"
URL="https://git.unom.io/unom/punktfunk/releases/download/${TAG}/punktfunk-host-setup-${VER}.exe"
# Re-download and re-hash the real bytes; the sidecar is a cross-check, never the truth.
curl -fsSL "$URL" -o /tmp/installer.exe
curl -fsSL "$URL.sha256" -o /tmp/installer.sha256
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
grep -qi "$SHA" /tmp/installer.sha256 || {
echo "ERROR: installer sha256 $SHA does not match the release's .sha256 sidecar" >&2
exit 1
}
CHANNEL=stable VERSION="$VER" REQUIRE_KEY=1 \
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
NOTES_URL="https://git.unom.io/unom/punktfunk/releases/tag/${TAG}" \
bash scripts/ci/publish-update-manifest.sh
- name: Post release announcement to Discord
env:
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+7
View File
@@ -210,6 +210,13 @@ jobs:
rm -rf dist-gamescope # never cache a failed build (an empty path is not saved)
fi
# NOTE deliberately NO sysext image is built or published here: a prebuilt HOST binary on
# SteamOS breaks on the next A/B soname bump (and /var — where sysexts live — is
# per-partition-set), which is the standing packaging verdict behind the on-device
# distrobox build (scripts/steamdeck/, see scripts/steamdeck/README.md). That flow builds
# its own HDR gamescope too. packaging/arch/build-sysext.sh remains a by-hand tool for the
# Deck CLIENT image and for operators who accept the prebuilt-host trade-off.
- name: Publish to the Gitea Arch registry
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
+6 -5
View File
@@ -147,15 +147,16 @@ jobs:
PUNKTFUNK_BUILD_VERSION: ${{ env.VERSION }} # stamped into the binaries (build.rs)
run: |
git config --global --add safe.directory "$PWD"
# THREE binaries ship in the client .deb, so all three are built here: the GTK shell,
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect), and
# punktfunk-cli (the headless `punktfunk` front-end). build-client-deb.sh installs all
# three; leaving punktfunk-cli out here made it fall over on `install: No such file or
# FOUR binaries ship in the client .deb, so all four are built here: the GTK shell,
# punktfunk-client-session (the Vulkan/Skia streamer the shell execs for a connect),
# punktfunk-cli (the headless `punktfunk` front-end), and pf-update (the root helper
# behind `punktfunk-client --apply-update`). build-client-deb.sh installs all four;
# leaving punktfunk-cli out here made it fall over on `install: No such file or
# directory`, because its build-if-missing guard only tested the first two and so decided
# everything was already built. The HOST is built separately in the build-publish-host
# job (Ubuntu 24.04 image + bundled FFmpeg 8).
cargo build --release --locked \
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli
-p punktfunk-client-linux -p punktfunk-client-session -p punktfunk-cli -p pf-update
- name: Build + smoke-boot web console (bun preset)
# Gate the .deb on a real bun boot: the punktfunk-web .deb runs the Nitro `bun` preset
+79 -10
View File
@@ -348,6 +348,21 @@ jobs:
Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue
Write-Output "web console smoke (bun): /login -> $code"
if ($code -ne 200) { throw "web console failed to boot under bun" }
# WEB_OUTPUT_DIR has to be exported whether or not the step above ran. It used to be that step's
# last line, so a CACHE HIT skipped it and left the variable unset — and pack-host-installer.ps1
# treats an unset WEB_OUTPUT_DIR as "don't bundle the console", silently ("installer built
# WITHOUT the web console"). That shipped in 0.22.1 and 0.22.2: no {app}\web, so no web-run.cmd,
# so `web setup` bails, so no PunktfunkWeb task and no console at all. It also removed the only
# thing that stopped bun before the copy (StopBunRuntimes was #ifdef WithWeb), while bun.exe kept
# shipping under WithScripting — which is the "DeleteFile failed; code 5" modal on bun.exe.
# The throw is the point: never silently ship a console-less installer again.
- name: Export the console output dir (cache hit or fresh build)
shell: pwsh
run: |
if (-not (Test-Path 'web\.output\server\index.mjs')) {
throw "web\.output is missing - neither the cache restore nor the build produced it, and the installer must not ship without the console"
}
"WEB_OUTPUT_DIR=$((Resolve-Path 'web\.output').Path)" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
- name: Build plugin/script runner bundle (bun)
@@ -368,16 +383,40 @@ jobs:
}
"SCRIPTING_BUNDLE=C:\t\scripting\runner-cli.js" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
# The UMDF drivers build IN-TREE inside the pack step (a relocated CARGO_TARGET_DIR
# breaks wdk-build's manifest walk), and checkout's clean wipes that tree every run —
# ~1 min of rebuild for crates that rarely change. Cache the in-tree target; cargo's
# own fingerprints decide what's still fresh after a restore.
- name: Cache drivers workspace target (built in-tree by the pack step)
uses: actions/cache@v4
with:
path: packaging/windows/drivers/target
key: drivers-target-${{ hashFiles('packaging/windows/drivers/**', 'crates/pf-driver-proto/**') }}
restore-keys: drivers-target-
# NOT cached, and it must stay that way: the UMDF drivers build IN-TREE inside the pack
# step (a relocated CARGO_TARGET_DIR breaks wdk-build's manifest walk), and act rotates
# the job workspace path (~/.cache/act/<hash>/hostexecutor) between runs. A cargo target
# dir restored under a DIFFERENT absolute path brings state that points at the old one:
# measured 2026-07-30, `pf-umdf-util` died with 14 × "unable to create file lock (os
# error 3)" and took the whole job with it. ~1 min of rebuild is the correct price; the
# same rotation is why the other Windows jobs use a fixed C:\t instead of a cached
# workspace-relative target.
# Every payload this job is SUPPOSED to bundle, asserted before packing. The packer treats each
# one as optional — correct for a local debug pack, and the reason 0.22.1/0.22.2 shipped with no
# web console: an unset WEB_OUTPUT_DIR omitted it behind a single Write-Host. CI knows it bundles
# all of these, so here a missing input is a build failure rather than a quietly smaller
# installer. (pack-host-installer.ps1 already does this for VB-CABLE, for the same reason.)
- name: Verify every installer payload is present
shell: pwsh
run: |
$need = @(
@{ n = 'web console (WEB_OUTPUT_DIR)'; p = $env:WEB_OUTPUT_DIR; f = 'server\index.mjs' }
@{ n = 'bun runtime (BUN_EXE)'; p = $env:BUN_EXE; f = '' }
@{ n = 'plugin runner (SCRIPTING_BUNDLE)';p = $env:SCRIPTING_BUNDLE; f = '' }
@{ n = 'FFmpeg DLLs (FFMPEG_DIR\bin)'; p = $env:FFMPEG_DIR; f = 'bin' }
@{ n = 'VB-CABLE (VBCABLE_DIR)'; p = $env:VBCABLE_DIR; f = 'VBCABLE_Setup_x64.exe' }
)
$missing = @()
foreach ($x in $need) {
if (-not $x.p) { $missing += "$($x.n): env var not set"; continue }
$full = if ($x.f) { Join-Path $x.p $x.f } else { $x.p }
if (-not (Test-Path $full)) { $missing += "$($x.n): missing $full" }
else { Write-Output "payload OK - $($x.n) -> $full" }
}
if ($missing.Count) {
$missing | ForEach-Object { Write-Output "MISSING PAYLOAD - $_" }
throw "$($missing.Count) installer payload(s) missing - refusing to ship an incomplete installer"
}
- name: Pack + sign installer
shell: pwsh
@@ -463,6 +502,36 @@ jobs:
# A separate Linux job, not another step in `package`: the deploy actions are Docker-based and do
# not run on a Windows runner. `needs: package` also gives the ordering that matters — build-data
# reads the manifests from the release, so it must not run before they are attached.
# Publish the SIGNED canary update manifest after the canary installer lands (planning:
# host-update-from-web-console.md §3.3 — canary rides this workflow because the installer is
# the only artifact the manifest references by URL; other canary channels may trail by minutes,
# which the per-PM apply path tolerates). A Linux job: the signer is bash+openssl. Skips (with
# a warning) when UPDATE_MANIFEST_KEY is absent — a canary build must not fail over it.
canary-manifest:
needs: package
if: gitea.ref == 'refs/heads/main'
runs-on: ubuntu-24.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- name: Publish the canary update manifest
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
UPDATE_MANIFEST_KEY: ${{ secrets.UPDATE_MANIFEST_KEY }}
run: |
set -euo pipefail
# Same derivation the package job used: canary = <next-minor base>'s major.minor + run#.
eval "$(bash scripts/ci/pf-version.sh)"
VER="${PF_MAJOR}.${PF_MINOR}.${GITHUB_RUN_NUMBER}"
URL="https://${REGISTRY}/api/packages/${OWNER}/generic/${PKG}/${VER}/punktfunk-host-setup-${VER}.exe"
curl -fsSL "$URL" -o /tmp/installer.exe
SHA="$(sha256sum /tmp/installer.exe | awk '{print $1}')"
CHANNEL=canary VERSION="$VER" CI_RUN="${GITHUB_RUN_NUMBER}" \
WINDOWS_URL="$URL" WINDOWS_SHA256="$SHA" \
NOTES_URL="https://git.unom.io/unom/punktfunk/releases" \
bash scripts/ci/publish-update-manifest.sh
winget-source:
needs: package
if: startsWith(gitea.ref, 'refs/tags/v')
+8 -4
View File
@@ -139,16 +139,20 @@ jobs:
# Both client binaries. ARM64: no skia-binaries prebuilt for the target, so the session
# drops its `ui` feature there (pf-console-ui excluded; --no-default-features is a no-op
# for the shell, which has no features).
# punktfunk-cli is in every gate: windows-msix.yml ships its `punktfunk.exe` alias, so
# a CLI that only the release workflow compiles is a release-day surprise. Its tests
# RUN the binary (help contract), as the session's contract_smoke runs the session —
# the gate class that catches a compiling-but-wrong binary (the 0.22.0 clobber).
- name: Build
shell: pwsh
run: |
$sf = @(); if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') }
cargo build -p punktfunk-client-windows -p punktfunk-client-session @sf --target ${{ matrix.target }}
cargo build -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli @sf --target ${{ matrix.target }}
- name: Clippy (-D warnings)
shell: pwsh
run: |
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
$pkgs = @('-p','punktfunk-client-windows','-p','punktfunk-client-session','-p','punktfunk-cli','-p','pf-client-core','-p','pf-presenter','-p','pf-ffvk')
$sf = @()
if ('${{ matrix.target }}' -eq 'aarch64-pc-windows-msvc') { $sf = @('--no-default-features') } else { $pkgs += @('-p','pf-console-ui') }
cargo clippy @pkgs --all-targets @sf --target ${{ matrix.target }} -- -D warnings
@@ -156,9 +160,9 @@ jobs:
- name: Rustfmt check
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
run: cargo fmt -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk -- --check
- name: Test
if: matrix.target == 'x86_64-pc-windows-msvc'
shell: pwsh
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
run: cargo test -p punktfunk-client-windows -p punktfunk-client-session -p punktfunk-cli -p pf-client-core -p pf-presenter -p pf-console-ui -p pf-ffvk --target ${{ matrix.target }}
+55 -11
View File
@@ -1,10 +1,10 @@
# Contributing to punktfunk
# Contributing to Punktfunk
Thanks for your interest in contributing!
## Licensing of contributions (inbound = outbound)
punktfunk is dual-licensed under **MIT OR Apache-2.0**.
Punktfunk is dual-licensed under **MIT OR Apache-2.0**.
> Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in
> the work by you, as defined in the Apache-2.0 license, shall be dual licensed as **MIT OR
@@ -28,6 +28,28 @@ If you add a new third-party dependency, it must be permissive (MIT / Apache-2.0
Unicode-3.0 / etc.). `about.toml` holds the accepted-license allow-list; regenerate the attribution
file with `scripts/gen-third-party-notices.sh` when the dependency tree changes.
## Prerequisites
The Rust toolchain is **pinned exactly** in `rust-toolchain.toml`; rustup installs it for you the
first time you build, so don't override it — a different rustc reformats files nobody touched.
The workspace links real system libraries, so a bare `cargo build --workspace` fails on a stock
machine. The authoritative list is what CI installs, in `ci/rust-ci.Dockerfile` — on **Ubuntu 26.04**,
which is what gets you FFmpeg 8:
```sh
sudo apt install build-essential clang libclang-dev pkg-config cmake \
libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavfilter-dev libavdevice-dev \
libpipewire-0.3-dev libopus-dev libwayland-dev libxkbcommon-dev \
libgl-dev libegl-dev libgbm-dev \
libgtk-4-dev libadwaita-1-dev libsdl3-dev \
libvulkan-dev
```
(The last two groups are the Linux client shell and `pf-ffvk`; skip them only if you never build
those crates. `scripts/bootstrap-ubuntu.sh` sets up an Ubuntu **capture-test host** — NVIDIA, Sway,
PipeWire — and is not a substitute for the list above.)
## Before you push
Enable the repo git hooks once per clone — they run the exact rustfmt gates CI runs (main
@@ -38,18 +60,40 @@ on formatting alone:
git config core.hooksPath scripts/git-hooks
```
Then the usual full pass:
Then the usual full pass. Use `--locked` as CI does — otherwise a silent `Cargo.lock` update can pass
locally and fail CI:
```sh
cargo fmt --all --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
cargo clippy --workspace --all-targets --locked -- -D warnings
cargo test --workspace --locked
```
Generated artifacts are checked in and CI fails on drift: `include/punktfunk_core.h` (cbindgen) and
`api/openapi.json` (`cargo run -p punktfunk-host -- openapi`). Match the surrounding code's comment
density and naming. Commit messages end with the `Co-Authored-By` trailer (see `git log`).
Two more gates that only apply to some changes:
See the [README's Build & test section](README.md#build--test-from-source) and
[Design invariants](README.md#design-invariants) for the full build/test/run guide, and the
[docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
- **Touched `web/` or `docs-site/`?** CI builds and typechecks both. Run, in that directory:
```sh
bun install && bun run build && bun run lint
```
Build first — it generates the API client / MDX typegen that the typecheck imports.
- **Touched Windows- or Linux-gated code from another OS?** `scripts/xcheck.sh windows` (or
`linux`) type-checks and lints that platform's `#[cfg(target_os = …)]` code in about a second,
instead of waiting for the CI job that compiles it.
Generated artifacts are checked in. `include/punktfunk_core.h` (cbindgen) is regenerated by the build
and CI fails if the committed copy drifts. `api/openapi.json` is **not** gated — nothing in CI
regenerates or diffs it, so regenerate and commit it yourself whenever you touch the management API,
and copy the snapshot the docs site serves:
```sh
cargo run -p punktfunk-host -- openapi > api/openapi.json
cp api/openapi.json docs-site/public/openapi.json
```
Match the surrounding code's comment density and naming. Commit messages end with the
`Co-Authored-By` trailer (see `git log`).
See the [README's Build & test section](README.md#build--test-from-source) for the extra dev
commands (the FEC loss harness, the standalone C-ABI proof) and
[Design invariants](README.md#design-invariants) for the rules a change is expected to hold to, and
the [docs site](https://docs.punktfunk.unom.io) for architecture and per-platform guides.
Generated
+52 -35
View File
@@ -947,7 +947,7 @@ dependencies = [
[[package]]
name = "cursor-probe"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-capture",
@@ -1036,7 +1036,7 @@ dependencies = [
[[package]]
name = "display-disturb"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"windows 0.62.2 (registry+https://github.com/rust-lang/crates.io-index)",
]
@@ -2221,7 +2221,7 @@ dependencies = [
[[package]]
name = "latency-probe"
version = "0.22.0"
version = "0.22.3"
[[package]]
name = "lazy_static"
@@ -2326,7 +2326,7 @@ dependencies = [
[[package]]
name = "libvpl-sys"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"bindgen",
"cmake",
@@ -2361,7 +2361,7 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "loss-harness"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"punktfunk-core",
]
@@ -2850,7 +2850,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pf-capture"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -2871,7 +2871,7 @@ dependencies = [
[[package]]
name = "pf-client-core"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -2880,6 +2880,7 @@ dependencies = [
"mdns-sd",
"opus",
"pf-ffvk",
"pf-update-check",
"pipewire",
"punktfunk-core",
"pyrowave-sys",
@@ -2896,7 +2897,7 @@ dependencies = [
[[package]]
name = "pf-clipboard"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -2914,7 +2915,7 @@ dependencies = [
[[package]]
name = "pf-console-ui"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -2935,7 +2936,7 @@ dependencies = [
[[package]]
name = "pf-encode"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -2959,7 +2960,7 @@ dependencies = [
[[package]]
name = "pf-ffvk"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"ash",
"bindgen",
@@ -2968,7 +2969,7 @@ dependencies = [
[[package]]
name = "pf-frame"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"libc",
@@ -2980,7 +2981,7 @@ dependencies = [
[[package]]
name = "pf-gpu"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-host-config",
@@ -2994,11 +2995,11 @@ dependencies = [
[[package]]
name = "pf-host-config"
version = "0.22.0"
version = "0.22.3"
[[package]]
name = "pf-inject"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3027,14 +3028,14 @@ dependencies = [
[[package]]
name = "pf-paths"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"tracing",
]
[[package]]
name = "pf-presenter"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -3047,9 +3048,29 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "pf-update"
version = "0.22.3"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "pf-update-check"
version = "0.22.3"
dependencies = [
"anyhow",
"base64",
"ring",
"serde",
"serde_json",
"ureq",
]
[[package]]
name = "pf-vdisplay"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"ashpd",
@@ -3082,7 +3103,7 @@ dependencies = [
[[package]]
name = "pf-win-display"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"pf-paths",
@@ -3094,7 +3115,7 @@ dependencies = [
[[package]]
name = "pf-zerocopy"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"ash",
@@ -3302,7 +3323,7 @@ dependencies = [
[[package]]
name = "punktfunk-cli"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"pf-client-core",
"punktfunk-core",
@@ -3313,7 +3334,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-android"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"android_logger",
"jni",
@@ -3329,7 +3350,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-linux"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"async-channel",
@@ -3346,18 +3367,13 @@ dependencies = [
[[package]]
name = "punktfunk-client-session"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"async-channel",
"glib-build-tools",
"gtk4",
"libadwaita",
"pf-client-core",
"pf-console-ui",
"pf-presenter",
"punktfunk-core",
"relm4",
"serde_json",
"tracing",
"tracing-subscriber",
@@ -3366,7 +3382,7 @@ dependencies = [
[[package]]
name = "punktfunk-client-windows"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"async-channel",
"ffmpeg-next",
@@ -3386,7 +3402,7 @@ dependencies = [
[[package]]
name = "punktfunk-core"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"aes-gcm",
"bytes",
@@ -3418,7 +3434,7 @@ dependencies = [
[[package]]
name = "punktfunk-host"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"aes",
"aes-gcm",
@@ -3454,6 +3470,7 @@ dependencies = [
"pf-host-config",
"pf-inject",
"pf-paths",
"pf-update-check",
"pf-vdisplay",
"pf-win-display",
"pf-zerocopy",
@@ -3502,7 +3519,7 @@ dependencies = [
[[package]]
name = "punktfunk-probe"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"mdns-sd",
@@ -3516,7 +3533,7 @@ dependencies = [
[[package]]
name = "punktfunk-tray"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"anyhow",
"ksni",
@@ -3539,7 +3556,7 @@ checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "pyrowave-sys"
version = "0.22.0"
version = "0.22.3"
dependencies = [
"bindgen",
"cmake",
+3 -1
View File
@@ -12,6 +12,8 @@ members = [
"crates/pf-ffvk",
"crates/pf-driver-proto",
"crates/pf-paths",
"crates/pf-update",
"crates/pf-update-check",
"crates/pf-host-config",
"crates/pf-gpu",
"crates/pf-zerocopy",
@@ -51,7 +53,7 @@ exclude = [
ndk = { path = "clients/android/native/vendor/ndk" }
[workspace.package]
version = "0.22.0"
version = "0.22.3"
edition = "2021"
rust-version = "1.82"
license = "MIT OR Apache-2.0"
+72 -24
View File
@@ -1,5 +1,5 @@
<p align="center">
<img src="assets/punktfunk-logo.svg" alt="punktfunk" width="320" />
<img src="assets/punktfunk-logo.svg" alt="Punktfunk" width="320" />
</p>
<p align="center"><b>Low-latency desktop and game streaming with first-class Linux and Windows hosts.</b></p>
@@ -18,7 +18,7 @@ access** · **[r/Punktfunk](https://www.reddit.com/r/Punktfunk/)**.
🔒 **Security:** found a vulnerability? Report it privately to **security@punktfunk.com** — see
[SECURITY.md](SECURITY.md). Please don't open a public issue.
punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
Punktfunk pairs a **virtual-display streaming host** with native clients on every platform. It speaks
the existing **GameStream** protocol, so any [Moonlight](https://moonlight-stream.org/) client works
day one — and adds its own faster **`punktfunk/1`** protocol that breaks the ~1 Gbps FEC wall with a
**GF(2¹⁶) Leopard-RS** transport. A single shared **Rust core** (`punktfunk-core`) holds the
@@ -43,8 +43,13 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
- **Low latency, GPU end to end.** Frames go straight from the compositor to the NVENC encoder with
zero CPU copies (dmabuf → CUDA/Vulkan → NVENC), over a transport tuned for responsiveness rather
than throughput. Stable 240 fps at 5120×1440; sub-millisecond capture-to-reassembly on-box,
~1.3 ms cross-machine on a LAN. (AMD/Intel encode via VAAPI, and a GPU-less software H.264
encoder exists as a fallback.)
~1.3 ms cross-machine on a LAN. (On Linux AMD/Intel, Vulkan Video for HEVC and AV1 with VAAPI for
H.264 and as the fallback; a GPU-less software H.264 encoder exists as a last resort.)
- **A library that fills itself.** Steam and non-Steam titles show up as a grid on every client, and
plugins add their own sources — ROM Manager (your ROM collection, matched to installed emulators),
Playnite, VirtualHere. Install them from the console's **Plugins** page or with
`punktfunk-host plugins add`. See
[Plugins](https://docs.punktfunk.unom.io/docs/plugins).
- **Works with what you already have.** Any Moonlight/Artemis client connects over GameStream — and
native apps for macOS, Linux, Windows, and Android use the lower-latency `punktfunk/1` protocol.
- **Secure by default.** Hosts require a one-time SPAKE2 **PIN pairing**; after that, devices
@@ -58,12 +63,12 @@ on Linux and Windows, and over a stable C ABI from the Apple and Android apps.
| **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 |
| **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: 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 |
| **Windows host** (Windows 11 22H2+, x64) | ✅ Beta — 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 (HEVC, and AV1 on hardware that decodes it), controllers incl. DualSense, discovery, pairing, speed test |
| **Linux client** (`clients/linux` + `clients/session`) | ✅ Streaming live: relm4/GTK4 launcher shell that spawns a Vulkan session binary — Vulkan Video / VAAPI / software decode, PipeWire audio, SDL3 controllers, Skia console UI; ships as Flatpak/apt/rpm/Arch |
| **Android client** (`clients/android`, phone + TV) | ✅ Streaming live: AMediaCodec decode + HDR10, AAudio audio, controllers, discovery, pairing |
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). HDR10 implemented, on-glass validation pending |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, GPU selection, performance capture graphs, live host logs |
| **Windows client** (`clients/windows`, WinUI 3) | ✅ Streaming live: WinUI 3 shell + Vulkan session presenter, hardware decode on all GPU vendors via Vulkan Video → D3D11VA → software (NVIDIA + Intel validated on glass), WASAPI audio, SDL3 controllers, discovery, pairing; ships as signed MSIX (x64 + ARM64). Hardware decode and HDR10 present validated on glass on NVIDIA and Intel, including HDR pass-through on the Intel D3D11VA path |
| **Web console + management API** (`web/`) | ✅ TanStack console over the OpenAPI mgmt API: host status, paired devices, on-demand PIN pairing, game library, virtual-display presets, plugin store, GPU selection, performance capture graphs, live host logs, host updates |
Every native client also ships a tiered **stats overlay** (Compact / Normal / Detailed) with a
shared vocabulary across platforms, and the session client carries a full gamepad-driven **console
@@ -81,36 +86,76 @@ Both run from **one process**: bare `punktfunk-host serve` is the **secure nativ
GameStream/Moonlight-compat planes (opt-in, trusted-LAN only — GameStream has inherent on-path
weaknesses). The host is managed through a REST API and web console. Builds against FFmpeg 7 or 8.
Full milestone status: **[docs.punktfunk.unom.io/docs/status](https://docs.punktfunk.unom.io/docs/status)** ·
roadmap: **[/docs/roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
What works where: **[the support matrix](https://docs.punktfunk.unom.io/docs/support-matrix)** ·
where it's heading: **[the roadmap](https://docs.punktfunk.unom.io/docs/roadmap)**.
## Install the host
Pick your platform and install from its package registry — the per-platform guide covers adding the
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; a
Windows host also ships as a signed installer (all-vendor: NVIDIA, AMD, Intel).
repo, first run, and the web console. The Linux host is the primary, most battle-tested path; on
SteamOS the host is built on-device by a script instead, and a Windows host ships as a signed
installer (all-vendor: NVIDIA, AMD, Intel).
| 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) |
| **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) |
| **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) |
| **Ubuntu / Debian** (apt) | `sudo apt install punktfunk-host` *(after adding the repo)* | [Ubuntu / Debian](https://docs.punktfunk.unom.io/docs/ubuntu) · [packaging/debian](packaging/debian/README.md) |
| **Bazzite / Fedora Atomic** (systemd-sysext) | `curl -fsSLO https://git.unom.io/unom/punktfunk/raw/branch/main/packaging/bazzite/punktfunk-sysext.sh && sudo bash punktfunk-sysext.sh install` *(no layering, no reboot; rpm-ostree + bootc also supported)* | [Bazzite](https://docs.punktfunk.unom.io/docs/bazzite) |
| **Fedora** (dnf) | `sudo dnf install punktfunk` *(after adding the repo; the console comes with it)* | [Fedora](https://docs.punktfunk.unom.io/docs/fedora) · [packaging/rpm](packaging/rpm/README.md) |
| **Arch / CachyOS** (pacman) | `sudo pacman -Syu punktfunk-host` *(binary repo — always a full `-Syu`)* | [Arch Linux](https://docs.punktfunk.unom.io/docs/arch) · [packaging/arch](packaging/arch/README.md) |
| **SteamOS / Steam Deck** (on-device build) | `bash ~/punktfunk/scripts/steamdeck/install.sh` *(after cloning this repo to `~/punktfunk`)* | [SteamOS (Host)](https://docs.punktfunk.unom.io/docs/steamos-host) |
| **Windows** (11 22H2+, x64) | `winget install unom.PunktfunkHost` *(after `winget source add -n punktfunk https://winget.punktfunk.unom.io -t Microsoft.Rest`)* · or the signed `setup.exe` from the package registry | [Windows Host](https://docs.punktfunk.unom.io/docs/windows-host) · [packaging/winget](packaging/winget/README.md) |
`punktfunk-host` is the streaming host; `punktfunk-web` is the browser console (pairing + status).
After install, run `punktfunk-host serve` inside your desktop session (the secure native default;
add `--gamestream` on a trusted LAN if you also want stock Moonlight clients), then pair from the web
console. Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
**Linux:** every package ships systemd **user** units, so you don't launch the host by hand. The
host unit won't start until `~/.config/punktfunk/host.env` exists, so copy the template your package
installed first:
```sh
mkdir -p ~/.config/punktfunk
# /usr/share/punktfunk/ on Fedora/Arch/Bazzite, /usr/share/punktfunk-host/ on Debian/Ubuntu
# (on Bazzite take host.env.bazzite instead)
cp /usr/share/punktfunk/host.env.example ~/.config/punktfunk/host.env
systemctl --user enable --now punktfunk-host # the streaming host
systemctl --user enable --now punktfunk-web # the web console (Arch: install punktfunk-web first)
```
The shipped host unit runs `serve --gamestream` — the native `punktfunk/1` plane **plus** the
GameStream/Moonlight-compat planes, which belong on a trusted LAN only; for a native-only host drop
the flag with a `systemctl --user edit punktfunk-host` drop-in (which needs an empty `ExecStart=`
line before the replacement — the install guide has the snippet). Then open
`https://<host-ip>:47992` and pair.
How the virtual display and input are wired up depends on your desktop — see
[KDE](https://docs.punktfunk.unom.io/docs/kde) · [GNOME](https://docs.punktfunk.unom.io/docs/gnome) ·
[Steam / gamescope](https://docs.punktfunk.unom.io/docs/gamescope) ·
[Sway](https://docs.punktfunk.unom.io/docs/sway).
**Windows:** the installer registers and starts the host as a `LocalSystem` service, so there is
nothing to run by hand — open the web console and pair. Use
`punktfunk-host service start|stop|restart|status` if you need to control it. Upgrades happen in
place — the console's **Updates** card, `winget upgrade unom.PunktfunkHost`, or the newer
`setup.exe` over the old install; uninstall from Add/Remove Programs.
Full instructions: **[docs.punktfunk.unom.io/docs/install](https://docs.punktfunk.unom.io/docs/install)**.
The console's **Host** page also shows when a newer host is out, along with the exact command for
how *this* box was installed (or a one-click **Update now** on Windows) — see
[Updating the host](https://docs.punktfunk.unom.io/docs/updating). To remove it again, or to go back
to an earlier version, see [Uninstalling](https://docs.punktfunk.unom.io/docs/uninstall) and
[Release Channels](https://docs.punktfunk.unom.io/docs/channels#pin-a-version-or-roll-back).
## Connect a client
| Streaming to… | Use |
|---|---|
| Mac, iPhone, iPad, Apple TV | The **Apple app** (`clients/apple`) — also on TestFlight |
| Linux desktop / laptop, Steam Deck | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
| Linux desktop / laptop | **`punktfunk-client`** (Flatpak / apt / rpm / Arch) |
| Steam Deck | The **Decky plugin** in Gaming Mode — it launches the client for you ([Steam Deck](https://docs.punktfunk.unom.io/docs/steam-deck)); in Desktop Mode, the Flatpak directly |
| Android phone or TV | The **Android app** (`clients/android`) |
| Windows | Native **`punktfunk-client`** (signed MSIX) or **Moonlight** |
| Scripts, automation, another launcher | **`punktfunk`** — the headless CLI shipped in the Linux client packages (`punktfunk pair`, `punktfunk hosts list --json`, `punktfunk launch <host>`) |
| Anything else (browser, old phone, smart TV) | **Moonlight** over GameStream |
Each client discovers hosts on the network automatically and does a one-time
@@ -122,7 +167,7 @@ Each client discovers hosts on the network automatically and does a one-time
For development, or as an install fallback where no package is available:
```sh
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, probe (Linux & macOS)
cargo build --workspace # core, host, tray, shared client crates, Linux shell + session client, the `punktfunk` CLI, probe (Linux & macOS)
cargo test --workspace # unit + loopback + proptest + C ABI harness
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
@@ -154,11 +199,14 @@ clients/
session/ punktfunk-session, the Vulkan streaming session (Rust · SDL3 · ash · Skia console UI) — also runs standalone (gamescope, Decky)
windows/ Windows desktop app (Rust · WinUI 3 · D3D11 · WASAPI · SDL3)
android/ Android phone + TV app (Kotlin · Rust JNI core · AMediaCodec · AAudio)
cli/ punktfunk, the headless client CLI — pair · hosts · wake · library · launch · punktfunk:// links
probe/ headless reference / measurement client for punktfunk/1
decky/ Steam Deck Decky plugin
web/ web console (TanStack) over the management API — status · devices · pairing · GPUs · performance · logs
web/ web console (TanStack) over the management API — status · devices · pairing · library · displays · plugins · GPUs · performance · logs · updates
api/openapi.json management-API OpenAPI spec (regenerated via `punktfunk-host openapi`, checked in)
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite bootc image
sdk/ `@punktfunk/host` — TypeScript management-API client + event stream (Effect)
plugin-kit/ `@punktfunk/plugin-kit` — the plugin authoring kit (bun / TypeScript)
packaging/ apt · rpm / COPR · Arch · Flatpak · Bazzite sysext + bootc · Windows installer + drivers · winget · Nix · gamescope
docs-site/ public documentation site (Fumadocs) — https://docs.punktfunk.unom.io
include/punktfunk_core.h cbindgen-generated C header (checked in)
tools/ latency-probe · loss-harness (measurement)
@@ -195,7 +243,7 @@ additional terms or conditions. See [CONTRIBUTING.md](CONTRIBUTING.md).
### Third-party components
punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
Punktfunk's own source is MIT/Apache-2.0. Shipped binaries additionally link third-party components
under their own (permissive) licenses — see [`THIRD-PARTY-NOTICES.txt`](THIRD-PARTY-NOTICES.txt)
(regenerate with `scripts/gen-third-party-notices.sh`). The Windows host and client builds also
bundle FFmpeg under the **LGPL v2.1+** (dynamically linked, replaceable DLLs; the license text and
@@ -203,7 +251,7 @@ notice ship in the installed `licenses/` folder).
### Trademarks
punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
Punktfunk is an independent project and is **not affiliated with, endorsed by, or sponsored by**
NVIDIA, Microsoft, Sony, Valve, or the Moonlight project. "GameStream", "Moonlight", "Xbox",
"DualSense", "DualShock", and "PlayStation" are trademarks of their respective owners and are used
here only to describe interoperability.
+12 -3
View File
@@ -1,8 +1,17 @@
# Security Policy
punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
Punktfunk is a low-latency desktop/game streaming stack. A host is effectively remote control of a
machine, so we take security reports seriously and appreciate responsible disclosure.
## Supported versions
Punktfunk ships on two tracks — **stable** (a `vX.Y.Z` tag; the current line is **0.22.x**) and
**canary** (built from `main`). Fixes ship as a new release on those tracks; in practice
we don't backport to older minor versions, so the supported versions are the latest stable release
and the current canary build. If you're on an older build, please check that the issue still
reproduces on the latest stable before reporting it. See
[Release Channels](https://docs.punktfunk.unom.io/docs/channels).
## Reporting a vulnerability
**Please report security issues privately by email to security@punktfunk.com.**
@@ -14,7 +23,7 @@ exposes other users before a fix exists.
The more of this you can give us, the faster we can act:
- The component and version (e.g. `punktfunk-host 0.9.0`, Windows or Linux, which client).
- The component and version (e.g. `punktfunk-host 0.22.3`, Windows or Linux, which client).
- The impact — what an attacker can do, and from what position (same LAN, a local service account,
admin, a paired client, …).
- Steps to reproduce, a proof-of-concept, or a crash/log if you have one.
@@ -98,4 +107,4 @@ pursue legal action against researchers who:
- give us reasonable time to remediate before public disclosure,
- don't exfiltrate more data than needed to demonstrate the issue.
Thank you for helping keep punktfunk and its users safe.
Thank you for helping keep Punktfunk and its users safe.
+25 -4
View File
@@ -18,6 +18,7 @@ VENDORED THIRD-PARTY SOURCE (inside first-party crates)
Vulkan-Headers (vendored, crates/pyrowave-sys) — https://github.com/KhronosGroup/Vulkan-Headers
Font Awesome Free brand icons (vendored, assets/os-icons) — https://fontawesome.com
Simple Icons (vendored, assets/os-icons) — https://simpleicons.org
Bazzite logo (vendored, assets/os-icons) — https://github.com/ublue-os/bazzite
----------------------------------------------------------------------------
MANIFEST (crate version — SPDX license — source)
@@ -2930,6 +2931,25 @@ IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
----------------------------------------------------------------------------
The following license (bazzite.txt) applies to: Bazzite logo (vendored, assets/os-icons)
----------------------------------------------------------------------------
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
the Bazzite source repository (repo_content/Bazzite.svg).
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
Licensed under the Apache License, Version 2.0,
https://www.apache.org/licenses/LICENSE-2.0.
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
gradient and decorative overlays were dropped, and the path was translated and scaled
into a 24x24 box with a monochrome fill (fill="currentColor").
Brand icons are trademarks of their respective owners and are used for identification
purposes only; their use does not imply endorsement.
----------------------------------------------------------------------------
The following license (LICENSE) applies to: bindgen 0.72.1
----------------------------------------------------------------------------
@@ -6283,8 +6303,8 @@ the following restrictions:
----------------------------------------------------------------------------
The following license (font-awesome-brands.txt) applies to: Font Awesome Free brand icons (vendored, assets/os-icons)
----------------------------------------------------------------------------
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
opensuse in assets/os-icons/) are from Font Awesome Free.
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
assets/os-icons/) are from Font Awesome Free.
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
@@ -12997,8 +13017,9 @@ SOFTWARE.
----------------------------------------------------------------------------
The following license (simple-icons.txt) applies to: Simple Icons (vendored, assets/os-icons)
----------------------------------------------------------------------------
Simple Icons — brand icons (arch, nixos, debian in assets/os-icons/) are from
Simple Icons (https://simpleicons.org, https://github.com/simple-icons/simple-icons).
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara in assets/os-icons/)
are from Simple Icons
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
+425 -1
View File
@@ -10,7 +10,7 @@
"name": "MIT OR Apache-2.0",
"identifier": "MIT OR Apache-2.0"
},
"version": "0.21.0"
"version": "0.22.3"
},
"paths": {
"/api/v1/clients": {
@@ -3432,6 +3432,142 @@
}
}
}
},
"/api/v1/update/apply": {
"post": {
"tags": [
"update"
],
"summary": "Apply the available update",
"description": "Starts the one-click apply for install kinds that support it (Windows installer). The\nrequest carries no version or URL — the host installs exactly what its verified manifest\nannounced. Progress is polled via `GET /update/status` (`job`); the host restarts as part\nof the apply, and the outcome lands in `last_result` after it comes back.",
"operationId": "applyUpdate",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApplyRequest"
}
}
},
"required": true
},
"responses": {
"202": {
"description": "Apply started — poll `GET /update/status`",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Refused: unsupported install kind, apply disabled (PUNKTFUNK_UPDATE_APPLY=0), a job already running, an active streaming session without `force`, or nothing newer to apply",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/update/check": {
"post": {
"tags": [
"update"
],
"summary": "Check for updates now",
"description": "Forces a manifest fetch + verification and returns the refreshed state. Rate-limited to\none forced check per 30 s.",
"operationId": "forceUpdateCheck",
"responses": {
"200": {
"description": "Refreshed update-check state (`last_error` carries a failed check)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"409": {
"description": "Update checks are disabled on this host",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
},
"429": {
"description": "A forced check ran less than 30 s ago",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
},
"/api/v1/update/status": {
"get": {
"tags": [
"update"
],
"summary": "Update-check status",
"description": "How this host was installed, which channel it follows, whether a newer release is known,\nand how to update. Reading this may kick a background refresh when the cached check is\nolder than 6 h; the response never blocks on the network.",
"operationId": "getUpdateStatus",
"responses": {
"200": {
"description": "Current update-check state",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/UpdateStatus"
}
}
}
},
"401": {
"description": "Missing or invalid bearer token",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiError"
}
}
}
}
}
}
}
},
"components": {
@@ -3764,6 +3900,15 @@
}
}
},
"ApplyRequest": {
"type": "object",
"properties": {
"force": {
"type": "boolean",
"description": "Proceed even while a streaming session is live (the stream will drop when the host\nrestarts — the console warns before sending this)."
}
}
},
"ApprovePending": {
"type": "object",
"description": "Approve-pending-device request body. Send `{}` to keep the device's own name.",
@@ -4799,6 +4944,59 @@
}
}
},
{
"type": "object",
"description": "A verified update manifest announced a release newer than the running host. Emitted\nonce per discovered version (a steady-state \"newer exists\" doesn't re-fire on every\nrefresh).",
"required": [
"version",
"channel",
"install_kind",
"kind"
],
"properties": {
"channel": {
"type": "string",
"description": "The channel it was announced on (`stable` | `canary`)."
},
"install_kind": {
"type": "string",
"description": "This host's install kind (`apt`, `windows-installer`, …) — lets a hook or the\ntray render the right \"how to update\" hint without a second call."
},
"kind": {
"type": "string",
"enum": [
"update.available"
]
},
"version": {
"type": "string",
"description": "The newer release's version string."
}
}
},
{
"type": "object",
"description": "A host update completed: emitted by boot-time reconciliation, i.e. by the NEW binary's\nfirst start after a successful apply.",
"required": [
"from",
"to",
"kind"
],
"properties": {
"from": {
"type": "string"
},
"kind": {
"type": "string",
"enum": [
"update.applied"
]
},
"to": {
"type": "string"
}
}
},
{
"type": "object",
"required": [
@@ -7040,6 +7238,228 @@
"type": "string"
}
}
},
"UpdateJobInfo": {
"type": "object",
"description": "A running apply job (or a spawned installer that hasn't resolved yet).",
"required": [
"target_version",
"stage",
"received_bytes",
"started_unix"
],
"properties": {
"received_bytes": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"stage": {
"type": "string",
"description": "`downloading` | `verifying` | `applying` | `restarting`."
},
"started_unix": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"target_version": {
"type": "string",
"description": "The version being installed."
},
"total_bytes": {
"type": [
"integer",
"null"
],
"format": "int64",
"minimum": 0
}
}
},
"UpdateManifestInfo": {
"type": "object",
"description": "One channel's manifest facts, as much as the console renders.",
"required": [
"version",
"serial",
"published_at",
"notes_url",
"stale"
],
"properties": {
"notes_url": {
"type": "string",
"description": "Release-notes link (pinned to our forge by the manifest validator)."
},
"published_at": {
"type": "string",
"description": "RFC-3339 publish time (display only)."
},
"serial": {
"type": "integer",
"format": "int64",
"description": "Publish serial (unix seconds) — monotonic per channel.",
"minimum": 0
},
"stale": {
"type": "boolean",
"description": "The last verified manifest is suspiciously old (>45 days) — the freeze/stale hint."
},
"version": {
"type": "string",
"description": "The released version this manifest announces."
}
}
},
"UpdateResultInfo": {
"type": "object",
"description": "Durable outcome of the most recent apply attempt (survives the host's own restart).",
"required": [
"ok",
"from",
"to",
"finished_unix"
],
"properties": {
"error": {
"type": [
"string",
"null"
]
},
"finished_unix": {
"type": "integer",
"format": "int64",
"minimum": 0
},
"from": {
"type": "string"
},
"log_path": {
"type": [
"string",
"null"
],
"description": "The installer's own log file on this host, for diagnosis."
},
"ok": {
"type": "boolean"
},
"stage": {
"type": [
"string",
"null"
],
"description": "The stage that failed; absent on success."
},
"staged": {
"type": "boolean",
"description": "Applied but activates on the next reboot (rpm-ostree)."
},
"to": {
"type": "string"
}
}
},
"UpdateStatus": {
"type": "object",
"description": "The full update-check state for this host.",
"required": [
"install_kind",
"channel",
"current_version",
"apply",
"channel_hint",
"check_disabled",
"available"
],
"properties": {
"apply": {
"type": "string",
"description": "What the console may offer for this install: `notify` (show the command) — later\nphases add `full` (one-click apply) and `staged` (apply + reboot to finish)."
},
"available": {
"type": "boolean",
"description": "A newer release than `current_version` exists for this channel (definitive\ncomparisons only — an unparseable version pair never flags)."
},
"channel": {
"type": "string",
"description": "Release channel this install follows: `stable` | `canary`."
},
"channel_hint": {
"type": "string",
"description": "The copy-pastable update command for this install kind."
},
"check_disabled": {
"type": "boolean",
"description": "Update checks are disabled on this host (`PUNKTFUNK_UPDATE_CHECK=0`)."
},
"current_version": {
"type": "string",
"description": "The running host version."
},
"install_kind": {
"type": "string",
"description": "How this host was installed: `windows-installer` | `sysext` | `rpm-ostree` | `apt` |\n`dnf` | `pacman` | `steamos-source` | `nix` | `source`."
},
"job": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/UpdateJobInfo",
"description": "The apply in flight, if any."
}
]
},
"last_checked_unix": {
"type": [
"integer",
"null"
],
"format": "int64",
"description": "When the last successful check happened (unix seconds).",
"minimum": 0
},
"last_error": {
"type": [
"string",
"null"
],
"description": "Why the last check failed, verbatim, if it did."
},
"last_result": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/UpdateResultInfo",
"description": "Outcome of the most recent apply attempt."
}
]
},
"manifest": {
"oneOf": [
{
"type": "null"
},
{
"$ref": "#/components/schemas/UpdateManifestInfo",
"description": "The last verified manifest, if any check has succeeded."
}
]
},
"opt_in_hint": {
"type": [
"string",
"null"
],
"description": "This install could one-click apply, but the operator hasn't opted in yet — the\ncommand to run (Linux: join the `punktfunk-update` group)."
}
}
}
},
"securitySchemes": {
@@ -7110,6 +7530,10 @@
{
"name": "store",
"description": "Plugin store: browse signed catalogs (verified first-party entries, attributed third-party sources), install/uninstall as tracked jobs, and switch the plugin runner on"
},
{
"name": "update",
"description": "Host update check: install kind + channel, the last verified release manifest, and whether a newer host exists (admin lane only)"
}
]
}
+14
View File
@@ -0,0 +1,14 @@
Bazzite — the `bazzite` mark in assets/os-icons/ is derived from the Bazzite logo in
the Bazzite source repository (repo_content/Bazzite.svg).
Copyright (c) Universal Blue (https://github.com/ublue-os/bazzite)
Licensed under the Apache License, Version 2.0,
https://www.apache.org/licenses/LICENSE-2.0.
Modifications: the logo's "b" letterform was lifted out of the surrounding badge, the
gradient and decorative overlays were dropped, and the path was translated and scaled
into a 24x24 box with a monochrome fill (fill="currentColor").
Brand icons are trademarks of their respective owners and are used for identification
purposes only; their use does not imply endorsement.
@@ -1,5 +1,5 @@
Font Awesome Free — brand icons (windows, apple, linux, steam, ubuntu, fedora,
opensuse in assets/os-icons/) are from Font Awesome Free.
Font Awesome Free — brand icons (apple, linux, steam, ubuntu, fedora, opensuse in
assets/os-icons/) are from Font Awesome Free.
Copyright (c) Fonticons, Inc. (https://fontawesome.com)
+3 -2
View File
@@ -1,5 +1,6 @@
Simple Icons — brand icons (arch, nixos, debian in assets/os-icons/) are from
Simple Icons (https://simpleicons.org, https://github.com/simple-icons/simple-icons).
Simple Icons — brand icons (arch, nixos, debian, cachyos, nobara in assets/os-icons/)
are from Simple Icons
(https://simpleicons.org, https://github.com/simple-icons/simple-icons).
The Simple Icons SVG path data is released under CC0 1.0 Universal (public domain
dedication), https://creativecommons.org/publicdomain/zero/1.0/ — no attribution
+32 -10
View File
@@ -7,7 +7,7 @@ Android `ImageVector`s). One file per **icon token** of the host's OS-identity c
| token | mark | source |
|---|---|---|
| `windows` | Windows | Font Awesome Free brands (CC BY 4.0) |
| `windows` | Windows (the current four-pane mark, no perspective skew) | own geometry |
| `apple` | Apple (also `macos` via alias) | Font Awesome Free brands (CC BY 4.0) |
| `linux` | Tux | Font Awesome Free brands (CC BY 4.0) |
| `steam` | Steam (also `steamos` via alias) | Font Awesome Free brands (CC BY 4.0) |
@@ -17,14 +17,36 @@ Android `ImageVector`s). One file per **icon token** of the host's OS-identity c
| `arch` | Arch Linux | Simple Icons (CC0 1.0) |
| `nixos` | NixOS | Simple Icons (CC0 1.0) |
| `debian` | Debian | Simple Icons (CC0 1.0) |
| `bazzite` | Bazzite | ublue-os/bazzite (Apache-2.0) |
| `cachyos` | CachyOS | Simple Icons (CC0 1.0) |
| `nobara` | Nobara | Simple Icons (CC0 1.0, slug `nobaralinux`) |
Distros with no file here (Bazzite, CachyOS, Nobara, Pop!_OS, Mint, …) are intentional:
the host advertises the full chain (`linux/fedora/bazzite`), and clients walk it
most-specific-first, so they degrade to the family mark and finally to Tux.
The last three are **distro leaves, not families**: a chain walks most-specific-first, so
`linux/fedora/bazzite` would otherwise draw the Fedora mark. They earn their own art because
"a Bazzite box" and "a Fedora box" are different machines to the person reading the card, and
they are what this project's hosts actually run. Every other distro with no file here (Pop!_OS,
Mint, …) still degrades to its family's mark and finally to Tux — that fallback is the design,
not a gap.
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved.
Licensing: attribution notices live in `LICENSES/` and are folded into
`THIRD-PARTY-NOTICES.txt` by `scripts/gen-third-party-notices.py`. The marks are
trademarks of their respective owners; they are used here nominatively — to *identify*
the operating system a host runs, the standard practice in this ecosystem — and imply no
affiliation or endorsement.
Windows is the one mark drawn here rather than sourced: every icon set that ships a "Windows"
brand glyph still carries the **Windows 8/10 flag with the perspective skew**, which reads as
dated next to the flat four-pane mark Microsoft has used since Windows 11. Four equal squares
(11.377 + 1.246 gap) is the current proportion.
All files are monochrome (`fill="currentColor"`), original per-icon viewBoxes preserved. Because
those viewBoxes are not all square, a client must letterbox rather than stretch — see the aspect
note in `clients/android/.../components/OsIcons.kt`.
## Regenerating the per-client derivatives
`bash scripts/gen-os-icons.sh [token ...]` turns a master into the three baked forms (GTK
symbolic SVG, Windows PNG, Apple template PDF) and prints the path data for the three clients
that inline it (web console, Decky plugin, Android). Adding a **new** token also means adding it
to each client's shipped-token list — the script prints that checklist too.
## Licensing
Attribution notices live in `LICENSES/` and are folded into `THIRD-PARTY-NOTICES.txt` by
`scripts/gen-third-party-notices.py`. The marks are trademarks of their respective owners; they
are used here nominatively — to *identify* the operating system a host runs, the standard
practice in this ecosystem — and imply no affiliation or endorsement.
+2
View File
@@ -0,0 +1,2 @@
<!-- bazzite — the Bazzite "b", from ublue-os/bazzite repo_content/Bazzite.svg (Apache-2.0), lifted out of the badge and normalized to a 24x24 box. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z"/></svg>

After

Width:  |  Height:  |  Size: 523 B

+2
View File
@@ -0,0 +1,2 @@
<!-- cachyos — from Simple Icons (CC0 1.0). See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81"/></svg>

After

Width:  |  Height:  |  Size: 438 B

+2
View File
@@ -0,0 +1,2 @@
<!-- nobara — from Simple Icons (CC0 1.0), slug "nobaralinux". See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z"/></svg>

After

Width:  |  Height:  |  Size: 681 B

+2 -2
View File
@@ -1,2 +1,2 @@
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="currentColor"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
<!-- windows — the modern (Windows 11) four-pane mark: four equal squares, no perspective skew. Own geometry, see README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><path d="M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z"/></svg>

Before

Width:  |  Height:  |  Size: 344 B

After

Width:  |  Height:  |  Size: 323 B

File diff suppressed because it is too large Load Diff
@@ -44,6 +44,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.Sc2Capture
import kotlinx.coroutines.delay
@@ -149,13 +150,14 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
) {
Text("Controllers", style = MaterialTheme.typography.headlineMedium)
// Steam Controller 2 detection: never an InputDevice (lizard mode is kb/mouse; the
// capture claims even those away), so it's enumerated on the capture side — USB device
// list + bonded BLE — and re-checked on USB hot-plug.
var sc2Generation by remember { mutableIntStateOf(0) }
// Capture-side detection, re-checked on USB hot-plug. The SC2 is never an InputDevice
// (lizard mode is kb/mouse; the capture claims even those away) so it's enumerated from
// the USB device list + bonded BLE; a Sony pad IS an InputDevice until claimed, so its
// row supplements the PadRow below with the capture status + the USB grant.
var usbGeneration by remember { mutableIntStateOf(0) }
DisposableEffect(Unit) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) { sc2Generation++ }
override fun onReceive(c: Context?, i: android.content.Intent?) { usbGeneration++ }
}
val filter = android.content.IntentFilter().apply {
addAction(android.hardware.usb.UsbManager.ACTION_USB_DEVICE_ATTACHED)
@@ -170,16 +172,23 @@ fun ControllersScreen(gamepadSetting: Int, onBack: () -> Unit) {
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
val sc2Probe = remember { Sc2Capture(context) }
val sc2Usb = remember(sc2Generation) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(sc2Generation) {
val sc2Usb = remember(usbGeneration) { sc2Probe.findUsbDevice() }
val sc2Ble = remember(usbGeneration) {
if (context.checkSelfPermission(android.Manifest.permission.BLUETOOTH_CONNECT) ==
android.content.pm.PackageManager.PERMISSION_GRANTED
) sc2Probe.pairedBleAddress() else null
}
val sc2Present = sc2Usb != null || sc2Ble != null
val dsUsb = remember(usbGeneration) {
(context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager)
.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
}
}
Group("Gamepads") {
if (sc2Present) Sc2Row(sc2Usb, activity)
dsUsb?.let { DsRow(it) }
if (pads.isEmpty() && !sc2Present) {
Text(
"No controller detected. punktfunk can only forward devices Android " +
@@ -319,6 +328,104 @@ private fun Sc2Row(usbDev: android.hardware.usb.UsbDevice?, activity: MainActivi
}
}
/**
* Broadcast action for the Sony-pad USB grants — fired by both the menu-time auto-ask
* ([MainActivity.maybeAskDsPermission]) and [DsRow]'s explicit button, so an open card
* refreshes whichever dialog was answered.
*/
internal const val DS_USB_PERMISSION_ACTION = "io.unom.punktfunk.DS_CONTROLLERS_USB_PERMISSION"
/**
* The Sony USB pad card — capture status + the USB grant. The grant normally arrives via the
* menu-time auto-ask the moment the pad attaches ([MainActivity.maybeAskDsPermission]); the
* button here is the recovery path after a deny (the auto-ask fires once per attach). Shown
* ALONGSIDE the pad's ordinary [PadRow] (unclaimed it is still an InputDevice); the capture
* itself only runs inside a stream, so at menu time this card is pure status.
*/
@Composable
private fun DsRow(usbDev: android.hardware.usb.UsbDevice) {
val context = LocalContext.current
val settingOn = remember { SettingsStore(context).load().dsCapture }
val usbManager = context.getSystemService(Context.USB_SERVICE) as android.hardware.usb.UsbManager
var permitted by remember(usbDev) { mutableStateOf(usbManager.hasPermission(usbDev)) }
val model = DsDevice.modelFor(usbDev.productId)
val label = when (model) {
DsDevice.Model.DUALSENSE -> "DualSense"
DsDevice.Model.DUALSENSE_EDGE -> "DualSense Edge"
DsDevice.Model.DUALSHOCK4 -> "DualShock 4"
null -> return
}
// Refresh `permitted` when the grant dialog answers (the grant itself is system-recorded;
// this receiver only updates the card).
val action = DS_USB_PERMISSION_ACTION
DisposableEffect(usbDev) {
val receiver = object : android.content.BroadcastReceiver() {
override fun onReceive(c: Context?, i: android.content.Intent?) {
if (i?.action == action) permitted = usbManager.hasPermission(usbDev)
}
}
androidx.core.content.ContextCompat.registerReceiver(
context,
receiver,
android.content.IntentFilter(action),
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED,
)
onDispose { runCatching { context.unregisterReceiver(receiver) } }
}
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
Text("$label passthrough", style = MaterialTheme.typography.bodyLarge)
Text(
"Wired (USB)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
when {
!settingOn -> Text(
"Passthrough is disabled in Settings — enable \"DualSense / DualShock " +
"passthrough (USB)\" to capture it.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
!permitted -> {
Text(
"Needs USB access — grant it now and streams capture the pad silently.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
OutlinedButton(onClick = {
usbManager.requestPermission(
usbDev,
android.app.PendingIntent.getBroadcast(
context, 3, // requestCode 3 — 0/1/2 are the SC2/stream grants
android.content.Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
android.app.PendingIntent.FLAG_MUTABLE,
),
)
}) {
Text("Grant USB access")
}
}
else -> Text(
if (model == DsDevice.Model.DUALSHOCK4) {
"Ready — captured at stream start: rumble, lightbar and gyro are " +
"driven directly."
} else {
"Ready — captured at stream start: rumble, adaptive triggers, lightbar " +
"and gyro are driven directly."
},
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
/** One detected gamepad: identity, what it streams as, and a rumble test. */
@Composable
private fun PadRow(dev: InputDevice, forwarded: Boolean, gamepadSetting: Int) {
@@ -49,7 +49,11 @@ suspend fun connectToHost(
host, port, w, h, hz,
identity.certPem, identity.privateKeyPem, pinHex,
settings.bitrateKbps, settings.compositor, gamepadPref,
hdrEnabled, settings.audioChannels,
hdrEnabled, VideoDecoders.multiSliceTolerant(),
// Slice-progressive delivery: decoder truth AND the async decode loop — the legacy
// sync loop feeds whole AUs only, so parts must never arrive when it is selected.
settings.lowLatencyMode && VideoDecoders.partialFrameCapable(),
settings.audioChannels,
// What this device can decode (H.264|HEVC always, AV1 when a real decoder exists) +
// the user's soft codec preference — the host resolves the emitted codec from both.
VideoDecoders.decodableCodecBits(), settings.preferredCodec(), timeoutMs,
@@ -28,6 +28,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import io.unom.punktfunk.kit.DsDevice
import io.unom.punktfunk.kit.Gamepad
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.Keymap
@@ -169,6 +170,10 @@ class MainActivity : ComponentActivity() {
private var sc2Receiver: BroadcastReceiver? = null
private var sc2PermissionAsked = false
/** Sony-pad USB grant asked this attach — a deny doesn't re-nag until a fresh attach (or the
* Controllers screen's explicit button). */
private var dsPermissionAsked = false
/**
* Compose focus hook for the SC2's synthetic D-pad (set by [onCreate]'s composition). A
* synthetic KeyEvent dispatched from OUTSIDE the real input pipeline never reaches
@@ -225,6 +230,8 @@ class MainActivity : ComponentActivity() {
UsbManager.ACTION_USB_DEVICE_ATTACHED -> {
sc2PermissionAsked = false // a fresh attach may ask once again
startSc2MenuNav()
dsPermissionAsked = false
maybeAskDsPermission()
}
SC2_MENU_PERMISSION -> {
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
@@ -281,6 +288,7 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
startSc2MenuNav()
maybeAskDsPermission()
}
override fun onPause() {
@@ -341,6 +349,37 @@ class MainActivity : ComponentActivity() {
sc2MenuActive = false
}
/**
* Ask for USB access to an attached Sony pad the moment it appears — a fresh attach while
* the app is open, or the app coming to the foreground with one already plugged in — at most
* once per attach, so the stream-mode capture ([io.unom.punktfunk.kit.DsCapture]) engages
* silently instead of interrupting stream start with the dialog. Unlike the SC2's menu flow
* there is nothing to START on the grant: an uncaptured Sony pad is an ordinary InputDevice
* at menu time, so the grant is simply recorded (Android keeps it while the pad stays
* attached). The broadcast only refreshes the Controllers screen's card if it happens to be
* open; a deny leaves that card's explicit button as the re-ask.
*/
private fun maybeAskDsPermission() {
if (streamHandle != 0L) return // StreamScreen owns its own permission flow while streaming
if (dsPermissionAsked) return
if (!SettingsStore(this).load().dsCapture) return
val usbManager = getSystemService(Context.USB_SERVICE) as UsbManager
val dev = usbManager.deviceList.values.firstOrNull {
it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS
} ?: return
if (usbManager.hasPermission(dev)) return
dsPermissionAsked = true
usbManager.requestPermission(
dev,
PendingIntent.getBroadcast(
this, 4, // requestCode 4 — 0..3 are the SC2 stream/menu + DS stream/card grants
Intent(DS_USB_PERMISSION_ACTION).setPackage(packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
/**
* One SC2 navigation key transition from the menu-time capture (main thread) — routed the
* same way [dispatchKeyEvent]'s not-streaming branch routes a real pad's buttons: B backs,
@@ -405,8 +444,8 @@ class MainActivity : ComponentActivity() {
/**
* 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.
* UI's scrolling/animation on a 120/144Hz panel. [StreamScreen] replaces this with
* [setStreamDisplayMode] while streaming (matched to the video, not to the panel maximum).
*/
fun setConsoleHighRefreshRate(high: Boolean) {
if (highRefreshModeId == 0) return
@@ -415,6 +454,64 @@ class MainActivity : ComponentActivity() {
}
}
/**
* Pin the panel to a display mode matching the STREAM's refresh for the session's duration —
* exact rate first, else the smallest integer multiple (120 for a 60 stream: judder-free 2:1
* pulldown), else the highest available. Same-resolution modes only.
*
* The window-level mode pin is the belt to the decoder's `ANativeWindow_setFrameRate` braces:
* the surface hint alone is advisory, and several OEM refresh governors (Nothing OS's LTPO
* logic among them) ignore it entirely for third-party apps — leaving a 120 Hz session
* presenting on a 60/90 Hz panel, which reads as judder + a refresh of extra latency. The
* preferredDisplayModeId is the one signal they all honor. [hz] ≤ 0 falls back to releasing
* the pin (the pre-pin behaviour).
*/
fun setStreamDisplayMode(hz: Int) {
if (hz <= 0) {
setConsoleHighRefreshRate(false)
return
}
val target = streamModeFor(hz) ?: return
window.attributes = window.attributes.apply { preferredDisplayModeId = target.modeId }
}
/**
* The panel refresh rate a [hz] stream runs against — [streamModeFor]'s pick, from the mode
* TABLE rather than `display.refreshRate`. The distinction matters: under a per-uid frame
* rate override (games get a 60 fps default on Android 15+) `refreshRate` reports the
* override, not the panel — observed on-glass as a 120 Hz panel reading back as 60. The
* supported-modes list is not override-filtered. `0` when unresolvable.
*/
fun streamPanelFps(hz: Int): Int =
streamModeFor(hz)?.refreshRate?.let { kotlin.math.round(it).toInt() } ?: 0
/** The same-resolution display mode [setStreamDisplayMode] pins for a [hz] stream. */
private fun streamModeFor(hz: Int): android.view.Display.Mode? {
if (hz <= 0) return null
@Suppress("DEPRECATION")
val disp = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) display else windowManager.defaultDisplay
val current = disp?.mode ?: return null
val sameRes = disp.supportedModes.filter {
it.physicalWidth == current.physicalWidth && it.physicalHeight == current.physicalHeight
}
fun multiple(rate: Float): Int {
val k = (rate / hz).toInt()
return if (k >= 2 && kotlin.math.abs(rate - hz * k) < 1f) k else 0
}
return sameRes.minWithOrNull(
compareBy(
{
when {
kotlin.math.abs(it.refreshRate - hz) < 1f -> 0 // exact
multiple(it.refreshRate) > 0 -> 1 // integer multiple — prefer smallest
else -> 2 // no relation — prefer highest so at least nothing is halved
}
},
{ if (multiple(it.refreshRate) > 0) it.refreshRate else -it.refreshRate },
),
)
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
val handle = streamHandle
if (handle != 0L) {
@@ -48,6 +48,9 @@ data class SettingsOverlay(
* else, but here it is the one knob a marginal link wants turned off per host.
*/
val lowLatencyMode: Boolean? = null,
/** The timeline presenter's intent pair — cross-client keys, see [Settings.presentPriority]. */
val presentPriority: String? = null,
val smoothBuffer: Int? = null,
/**
* Overlay keys a newer build wrote and this one doesn't model — carried through a load→save
* round-trip untouched. The don't-clobber rule: opening and saving a profile on an older client
@@ -73,6 +76,8 @@ data class SettingsOverlay(
gamepad = gamepad ?: base.gamepad,
statsVerbosity = statsVerbosity ?: base.statsVerbosity,
lowLatencyMode = lowLatencyMode ?: base.lowLatencyMode,
presentPriority = presentPriority ?: base.presentPriority,
smoothBuffer = smoothBuffer ?: base.smoothBuffer,
)
/**
@@ -104,6 +109,8 @@ data class SettingsOverlay(
gamepad = if (after.gamepad != before.gamepad) after.gamepad else gamepad,
statsVerbosity = if (after.statsVerbosity != before.statsVerbosity) after.statsVerbosity else statsVerbosity,
lowLatencyMode = if (after.lowLatencyMode != before.lowLatencyMode) after.lowLatencyMode else lowLatencyMode,
presentPriority = if (after.presentPriority != before.presentPriority) after.presentPriority else presentPriority,
smoothBuffer = if (after.smoothBuffer != before.smoothBuffer) after.smoothBuffer else smoothBuffer,
)
/**
@@ -127,6 +134,8 @@ data class SettingsOverlay(
"gamepad" -> copy(gamepad = null)
"stats_verbosity" -> copy(statsVerbosity = null)
"low_latency_mode" -> copy(lowLatencyMode = null)
"present_priority" -> copy(presentPriority = null)
"smooth_buffer" -> copy(smoothBuffer = null)
else -> this
}
@@ -147,6 +156,8 @@ data class SettingsOverlay(
if (gamepad != null) add("gamepad")
if (statsVerbosity != null) add("stats_verbosity")
if (lowLatencyMode != null) add("low_latency_mode")
if (presentPriority != null) add("present_priority")
if (smoothBuffer != null) add("smooth_buffer")
}
/**
@@ -175,6 +186,8 @@ data class SettingsOverlay(
gamepad?.let { j.put("gamepad", it) }
statsVerbosity?.let { j.put("stats_verbosity", it.name) }
lowLatencyMode?.let { j.put("low_latency_mode", it) }
presentPriority?.let { j.put("present_priority", it) }
smoothBuffer?.let { j.put("smooth_buffer", it) }
return j
}
@@ -187,6 +200,7 @@ data class SettingsOverlay(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "mic_enabled", "touch_mode",
"mouse_mode", "invert_scroll", "gamepad", "stats_verbosity", "low_latency_mode",
"present_priority", "smooth_buffer",
)
internal fun fromJson(j: JSONObject): SettingsOverlay = SettingsOverlay(
@@ -209,6 +223,8 @@ data class SettingsOverlay(
statsVerbosity = j.optStringOrNull("stats_verbosity")
?.let { n -> StatsVerbosity.entries.firstOrNull { it.name == n } },
lowLatencyMode = j.optBooleanOrNull("low_latency_mode"),
presentPriority = j.optStringOrNull("present_priority"),
smoothBuffer = j.optIntOrNull("smooth_buffer"),
extra = j.keys().asSequence().filter { it !in KNOWN }.associateWith { j.get(it) },
)
}
@@ -83,6 +83,19 @@ data class Settings(
* feeds a queue that only grows.
*/
val lowLatencyMode: Boolean = true,
/**
* The timeline presenter's intent — the cross-client `present_priority` pair (the Apple
* client's "Prioritize" picker, same stored values): `"latency"` (default) = newest-wins,
* a frame reaches glass the instant the glass budget opens; `"smooth"` = a small FIFO
* drained one frame per vsync, absorbing network/decode jitter at one refresh of added
* display latency per buffered frame. Anything unrecognized resolves to latency.
*/
val presentPriority: String = "latency",
/**
* The smoothness buffer depth (`smooth_buffer`): 0 = Automatic (2 frames), else 1..3.
* Only meaningful when [presentPriority] is `"smooth"`.
*/
val smoothBuffer: Int = 0,
/**
* Wake-on-LAN a saved host before connecting when it isn't currently seen on mDNS. On (default):
* a connect to a host with a learned MAC that isn't advertising sends a magic packet and waits
@@ -110,6 +123,18 @@ data class Settings(
*/
val sc2Capture: Boolean = true,
/**
* Capture a USB-connected Sony controller (DualSense / DualSense Edge / DualShock 4) and
* drive it directly: the app claims the pad's HID interface and renders the host's feedback
* by writing USB output reports — rumble works on every phone (no kernel force-feedback
* driver needed), and adaptive triggers + lightbar + player LEDs work at all (Android has no
* platform API for any of them). ON by default — it engages only when such a pad is attached
* over USB at stream start; uncaptured (toggle off / no permission / Bluetooth) the pad stays
* on the ordinary InputDevice path. USB only: Android exposes no raw path to a Bluetooth
* Classic pad, which is also why Sony's own Remote Play has no Android trigger support.
*/
val dsCapture: Boolean = true,
/**
* How a physical mouse drives the host — the cross-client mouse model (see [MouseMode]).
* [MouseMode.DESKTOP] (default here) points absolutely; [MouseMode.CAPTURE] locks the pointer
@@ -201,9 +226,12 @@ class SettingsStore(context: Context) {
gamepadUiEnabled = prefs.getBoolean(K_GAMEPAD_UI, true),
libraryEnabled = prefs.getBoolean(K_LIBRARY, true),
lowLatencyMode = prefs.getBoolean(K_LOW_LATENCY, true),
presentPriority = prefs.getString(K_PRESENT_PRIORITY, "latency") ?: "latency",
smoothBuffer = prefs.getInt(K_SMOOTH_BUFFER, 0),
autoWakeEnabled = prefs.getBoolean(K_AUTO_WAKE, true),
rumbleOnPhone = prefs.getBoolean(K_RUMBLE_ON_PHONE, false),
sc2Capture = prefs.getBoolean(K_SC2_CAPTURE, true),
dsCapture = prefs.getBoolean(K_DS_CAPTURE, true),
mouseMode = prefs.getString(K_MOUSE_MODE, null)
?.let { name -> MouseMode.entries.firstOrNull { it.storedName == name } }
// Migration: the pre-enum Boolean "pointer_capture" (true = lock the pointer). Its
@@ -231,9 +259,12 @@ class SettingsStore(context: Context) {
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
.putBoolean(K_LIBRARY, s.libraryEnabled)
.putBoolean(K_LOW_LATENCY, s.lowLatencyMode)
.putString(K_PRESENT_PRIORITY, s.presentPriority)
.putInt(K_SMOOTH_BUFFER, s.smoothBuffer)
.putBoolean(K_AUTO_WAKE, s.autoWakeEnabled)
.putBoolean(K_RUMBLE_ON_PHONE, s.rumbleOnPhone)
.putBoolean(K_SC2_CAPTURE, s.sc2Capture)
.putBoolean(K_DS_CAPTURE, s.dsCapture)
.putString(K_MOUSE_MODE, s.mouseMode.storedName)
.putBoolean(K_INVERT_SCROLL, s.invertScroll)
.apply()
@@ -271,9 +302,12 @@ class SettingsStore(context: Context) {
* on; both stale keys are abandoned unread. The toggle stays as a per-device escape hatch.
*/
const val K_LOW_LATENCY = "low_latency_mode_v2"
const val K_PRESENT_PRIORITY = "present_priority"
const val K_SMOOTH_BUFFER = "smooth_buffer"
const val K_AUTO_WAKE = "auto_wake_enabled"
const val K_RUMBLE_ON_PHONE = "rumble_on_phone"
const val K_SC2_CAPTURE = "sc2_capture"
const val K_DS_CAPTURE = "ds_capture"
const val K_MOUSE_MODE = "mouse_mode"
/** Legacy Boolean the [K_MOUSE_MODE] enum replaced — read once for migration, never written. */
@@ -491,6 +525,29 @@ val COMPOSITOR_OPTIONS = listOf(
/** (verbosity, label) for the stats-overlay detail picker. Order = the live 3-finger-tap cycle. */
val STATS_VERBOSITY_OPTIONS = StatsVerbosity.entries.map { it to it.label }
/** [Settings.presentPriority] as the wire int `nativeStartVideo` takes (0 = latency, 1 = smooth).
* Unrecognized values resolve to latency — same rule as the Apple client. */
fun Settings.presentPriorityWire(): Int = if (presentPriority == "smooth") 1 else 0
/** (stored value, label) for the presenter-intent picker — the Apple client's table verbatim. */
val PRESENT_PRIORITY_OPTIONS = listOf(
"latency" to "Lowest latency",
"smooth" to "Smoothness",
)
/** (frames, label) for the smoothness-buffer picker; each buffered frame ≈ one refresh interval
* of jitter absorbed for one interval of added display latency ([hz] labels the cost). */
fun smoothBufferOptions(hz: Int): List<Pair<Int, String>> {
val periodMs = 1000.0 / maxOf(24, hz)
fun cost(frames: Int) = "+%.0f ms".format(periodMs * frames)
return listOf(
0 to "Automatic",
1 to "1 frame (${cost(1)})",
2 to "2 frames (${cost(2)})",
3 to "3 frames (${cost(3)})",
)
}
/** (mode, label) for the touch-input model. */
val TOUCH_MODE_OPTIONS = listOf(
TouchMode.TRACKPAD to "Trackpad",
@@ -711,6 +711,26 @@ private fun DisplaySettings(s: Settings, update: (Settings) -> Unit, context: an
field = "low_latency_mode",
onCheckedChange = { on -> update(s.copy(lowLatencyMode = on)) },
)
// The timeline presenter's intent — the Apple client's "Prioritize" pair, same stored
// values, so a profile written on one platform means the same thing here.
SettingDropdown(
label = "Prioritize",
options = PRESENT_PRIORITY_OPTIONS,
selected = if (s.presentPriority == "smooth") "smooth" else "latency",
field = "present_priority",
caption = "Lowest latency shows each frame the moment it can reach the panel; " +
"Smoothness buffers a little to absorb network jitter.",
) { v -> update(s.copy(presentPriority = v)) }
if (s.presentPriority == "smooth") {
SettingDropdown(
label = "Smoothness buffer",
options = smoothBufferOptions(if (s.hz > 0) s.hz else nhz),
selected = if (s.smoothBuffer in 1..3) s.smoothBuffer else 0,
field = "smooth_buffer",
caption = "Each buffered frame absorbs one refresh of jitter and adds one of " +
"display latency — the cost shown is at the session's refresh rate.",
) { v -> update(s.copy(smoothBuffer = v)) }
}
}
SettingsGroup("Host output", footer = "Display changes apply from the next session.") {
@@ -816,6 +836,15 @@ private fun ControllerSettings(s: Settings, update: (Settings) -> Unit, onOpenCo
checked = s.sc2Capture,
onCheckedChange = { on -> update(s.copy(sc2Capture = on)) },
)
// Same no-vibrator-gate reasoning as the SC2 row: this capture renders feedback on
// the CONTROLLER's own motors/LEDs, not this device's.
ToggleRow(
title = "DualSense / DualShock passthrough (USB)",
subtitle = "Drive a USB-connected Sony pad directly — rumble on any phone, " +
"plus adaptive triggers, lightbar and gyro",
checked = s.dsCapture,
onCheckedChange = { on -> update(s.copy(dsCapture = on)) },
)
}
}
}
@@ -46,12 +46,20 @@ internal fun StatsOverlay(
* common case: no profile) the line is exactly what it always was.
*/
profileName: String? = null,
/**
* The panel's live refresh rate (0 = unknown). Shown as a warning on the first line whenever
* it sits below the stream rate — the "an OEM governor ignored the mode pin" tell, which
* otherwise reads as inexplicable judder and an extra refresh of latency.
*/
panelHz: Float = 0f,
modifier: Modifier = Modifier,
) {
if (verbosity == StatsVerbosity.OFF || s.size < 10) return
val w = s[6].toInt()
val h = s[7].toInt()
val hz = s[8].toInt()
val panelBelowStream = panelHz > 0f && hz > 0 && panelHz + 1f < hz.toFloat()
val panelTag = if (panelBelowStream) " ⚠ panel ${panelHz.roundToInt()} Hz" else ""
val latValid = s[4] != 0.0
val skew = s[5] != 0.0
val lost = s[9].toLong()
@@ -65,12 +73,12 @@ internal fun StatsOverlay(
val profileTag = profileName?.let { " · $it" }.orEmpty()
// Compact: everything the glance-value needs on one line, nothing else.
if (verbosity == StatsVerbosity.COMPACT) {
statLine(compactLine(s, latValid) + profileTag, Color.White)
statLine(compactLine(s, latValid) + profileTag + panelTag, Color.White)
return@Column
}
statLine(
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag",
"$w×$h@$hz ${s[0].roundToInt()} fps ${"%.1f".format(s[1])} Mb/s$profileTag$panelTag",
Color.White,
)
if (detailed && decoderLabel.isNotEmpty()) {
@@ -104,8 +112,27 @@ internal fun StatsOverlay(
} else {
"host+network ${"%.1f".format(s[14])}"
}
val displayTerm = if (dispValid) " + display ${"%.1f".format(s[23])}" else ""
statLine("= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm", Color.White)
// Timeline-presenter split (s[26]/s[27], when s[29] flags it active): the display
// term decomposes into pace (store + glass budget) + latch (SurfaceFlinger), and
// s[28] is the on-glass confirm count — presents ≪ fps means the presenter is
// dropping/serializing, an fps deficit is upstream.
val split = s.size >= 30 && s[29] != 0.0 && (s[26] > 0 || s[27] > 0)
val displayTerm = when {
dispValid && split ->
" + display ${"%.1f".format(s[23])} " +
"(pace ${"%.1f".format(s[26])} + latch ${"%.1f".format(s[27])})"
dispValid -> " + display ${"%.1f".format(s[23])}"
else -> ""
}
val presents = if (s.size >= 30 && s[29] != 0.0) {
" · presents ${s[28].toInt()}"
} else {
""
}
statLine(
"= $hostTerms + decode ${"%.1f".format(s[15])}$displayTerm$presents",
Color.White,
)
}
}
counterLine(s, lost)?.let { statLine(it, Color(0xFFFFB0B0)) }
@@ -54,6 +54,7 @@ import androidx.core.view.WindowInsetsControllerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.LifecycleOwner
import io.unom.punktfunk.kit.DsCapture
import io.unom.punktfunk.kit.GamepadFeedback
import io.unom.punktfunk.kit.GamepadRouter
import io.unom.punktfunk.kit.deviceBodyVibrator
@@ -62,6 +63,7 @@ import io.unom.punktfunk.kit.Sc2Capture
import io.unom.punktfunk.kit.VideoDecoders
import io.unom.punktfunk.models.ActiveSession
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.math.roundToInt
import kotlinx.coroutines.delay
/**
@@ -77,7 +79,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val micEnabled = initialSettings.micEnabled
val context = LocalContext.current
val activity = context as? MainActivity
// The View hosting this composition — the one that receives the stream's touch/pointer events
// (the gesture Box below is a Compose node inside it), so it is where unbuffered dispatch is
// requested.
val composeView = androidx.compose.ui.platform.LocalView.current
val window = activity?.window
// The negotiated stream refresh, known from the handshake (0 = unknown / older native lib) —
// drives the panel mode pin, the render-rate vote, and the presenter's latch grid.
val streamHz = remember(handle) { NativeBridge.nativeVideoSize(handle)?.getOrNull(2) ?: 0 }
val controller = remember(window) {
window?.let { WindowCompat.getInsetsController(it, it.decorView) }
}
@@ -99,6 +108,9 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
var stats by remember { mutableStateOf<DoubleArray?>(null) }
var decoderLabel by remember { mutableStateOf("") }
var codecLabel by remember { mutableStateOf("") }
// The panel's LIVE refresh rate, re-read each poll — the HUD flags a session whose panel sits
// below the stream rate (an OEM governor that ignored both the mode pin and the surface hint).
var panelHz by remember { mutableStateOf(0f) }
var statsVerbosity by remember { mutableStateOf(initialSettings.statsVerbosity) }
val statsOn = statsVerbosity != StatsVerbosity.OFF
// Touch model is fixed per session (re-keys the gesture handler below if it ever changes).
@@ -120,6 +132,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
while (true) {
delay(1000)
stats = NativeBridge.nativeVideoStats(handle)
panelHz = runCatching { context.display }.getOrNull()?.refreshRate ?: 0f
// The decoder is fixed for the session; fetch its label once it's resolved.
if (decoderLabel.isEmpty()) decoderLabel = NativeBridge.nativeVideoDecoderLabel(handle)
}
@@ -226,13 +239,39 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
val priorSoftInput = window?.attributes?.softInputMode
?: WindowManager.LayoutParams.SOFT_INPUT_ADJUST_UNSPECIFIED
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
// Draw under the display cutout, explicitly. Android 15's SDK-35 edge-to-edge enforcement
// makes ALWAYS the immersive default, but pre-15 devices letterbox the notch as a dead
// black bar unless asked — and the stream's own letterbox is black anyway, so the cutout
// region can never show anything wrong. Captured + restored like the rest of the window
// state so the menus keep their platform-default behaviour.
val priorCutout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.attributes?.layoutInDisplayCutoutMode
} else {
null
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
window?.let { w ->
w.attributes = w.attributes.apply {
layoutInDisplayCutoutMode =
WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_ALWAYS
}
}
}
// Lock to landscape while streaming — the host streams a landscape desktop, so pin the device
// there (either landscape direction is fine) and stop it rotating to portrait mid-session. The
// activity declares configChanges=orientation, so this re-lays out the surface in place without
// recreating the activity (no stream restart). On TV (fixed landscape) it's a harmless no-op.
// The prior request is captured and restored on the way out.
//
// COMPACT devices only (sw < 600 dp): on tablets/foldables/desktop windows the lock is a
// large-display anti-pattern (Play flags it; Android 16+ ignores it there outright), and the
// stream doesn't need it — the aspect-ratio letterbox renders correctly in any orientation,
// the lock is purely a phone-ergonomics choice.
val compactDevice = context.resources.configuration.smallestScreenWidthDp < 600
val priorOrientation = activity?.requestedOrientation
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
if (compactDevice) {
activity?.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE
}
activity?.streamHandle = handle // route hardware keys to this session
// Multi-controller router: a stable wire pad index per connected controller, per-device axis
// state, Arrival/Remove on hot-plug, and feedback routed back by pad index. Forwards every
@@ -304,7 +343,30 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
} else {
null
}
activity?.setConsoleHighRefreshRate(false) // let the decoder's setFrameRate pick the panel rate
// Pin the panel to the stream's refresh (exact / multiple) for the session. The decoder's
// own ANativeWindow_setFrameRate hint still aligns vsync, but it is advisory — some OEM
// refresh governors ignore it outright and would leave a 120 Hz session on a 60/90 Hz
// panel. TV boxes skip the pin: the native side actively drives the HDMI mode there.
if (isTv) {
activity?.setConsoleHighRefreshRate(false) // the decoder's HDMI mode switch governs
} else {
activity?.setStreamDisplayMode(streamHz)
}
// Touch/pointer events are vsync-batched by default — up to a frame of input latency the
// stream shouldn't pay. Unbuffered dispatch delivers them the moment the kernel does.
// Undone by passing 0 on the way out (API 30+).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
composeView.requestUnbufferedDispatch(android.view.InputDevice.SOURCE_CLASS_POINTER)
}
// Vote the app's RENDER rate up to the stream's (API 35+). The mode pin above governs the
// panel, but the platform separately down-rates a quiet app's choreographer stream
// (frame-rate categories: a non-animating UI reads as "normal" = 60) — observed on-glass
// as 16.6 ms vsync callbacks on a 120 Hz panel, which would pace the presenter at half
// rate. The native side also subdivides onto the panel grid, so this vote is the belt to
// that braces. Reset to no-preference on the way out.
if (Build.VERSION.SDK_INT >= 35 && streamHz > 0) {
composeView.requestedFrameRate = streamHz.toFloat()
}
// Host→client feedback (rumble + DualSense lightbar/LEDs), routed to each controller by pad
// index via the router; poll threads stopped + joined before the router is released and the
// session closed. "Rumble on this phone" (opt-in) additionally mirrors controller 1's
@@ -367,13 +429,59 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
}
}
}
// Sony pad capture (DualSense / Edge / DualShock 4, opt-out): claim a USB-connected
// pad's HID interface and drive it directly — rumble without a kernel force-feedback
// driver, plus adaptive triggers, lightbar, player LEDs and gyro/touchpad, none of which
// the InputDevice path can render (no platform API for any of them). Uncaptured (toggle
// off / permission denied / Bluetooth) the pad stays on the ordinary InputDevice path —
// the automatic fallback. Host feedback routes back through feedback.sink; the claim
// frees the pad's InputDevice slot itself (see DsCapture.startUsb), so the wire index
// hands over deterministically.
val ds = if (initialSettings.dsCapture) DsCapture(context, router) else null
var dsUsbReceiver: BroadcastReceiver? = null
if (ds != null) {
feedback.sink = ds
val usbManager = context.getSystemService(Context.USB_SERVICE) as UsbManager
val usbDev = ds.findUsbDevice()
when {
usbDev != null && usbManager.hasPermission(usbDev) -> ds.startUsb(usbDev)
usbDev != null -> {
// One-time system dialog; capture engages on grant (Android remembers the
// grant for as long as the device stays attached).
val action = "io.unom.punktfunk.DS_USB_PERMISSION"
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != action) return
val ok = intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)
if (ok) ds.startUsb(usbDev) else Log.i("punktfunk", "Sony pad USB permission denied")
}
}
dsUsbReceiver = receiver
ContextCompat.registerReceiver(
context, receiver, IntentFilter(action), ContextCompat.RECEIVER_NOT_EXPORTED,
)
usbManager.requestPermission(
usbDev,
PendingIntent.getBroadcast(
context, 2, // requestCode 2 — 0/1 are the SC2 stream/menu grants
Intent(action).setPackage(context.packageName),
// MUTABLE: the USB stack appends the grant extras to this intent.
PendingIntent.FLAG_MUTABLE,
),
)
}
}
}
onDispose {
closed.set(true) // from here the handle gets freed; surfaceDestroyed must not touch it
clip?.stop() // stop + join the clipboard poll thread BEFORE the handle is freed
feedback.onHidRaw = null
feedback.sink = null
feedback.stop() // stop + join the poll threads BEFORE the router is released / handle freed
sc2UsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
sc2?.stop() // release the USB/BLE link + free the wire slot (host tears the pad down)
dsUsbReceiver?.let { runCatching { context.unregisterReceiver(it) } }
ds?.stop() // rumble-stop on the physical pad + release the USB link + free the wire slot
router.onExitArmed = null // don't poke Compose state from release()'s disarm while tearing down
router.release() // flush every slot (nothing sticks host-side) + drop the hot-plug listener
activity?.gamepadRouter = null
@@ -388,8 +496,19 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
// Back in the menus: the SC2 (if present) resumes driving the console UI.
activity?.startSc2MenuNav()
activity?.setConsoleHighRefreshRate(true) // back to the console UI's max refresh
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
composeView.requestUnbufferedDispatch(0) // back to ordinary batched dispatch
}
if (Build.VERSION.SDK_INT >= 35) {
composeView.requestedFrameRate = View.REQUESTED_FRAME_RATE_CATEGORY_DEFAULT
}
controller?.hide(WindowInsetsCompat.Type.ime()) // drop any keyboard left showing
window?.setSoftInputMode(priorSoftInput)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R && priorCutout != null) {
window?.let { w ->
w.attributes = w.attributes.apply { layoutInDisplayCutoutMode = priorCutout }
}
}
controller?.show(WindowInsetsCompat.Type.systemBars())
window?.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
if (lowLatencyMode && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -480,6 +599,14 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
lowLatencyMode,
choice?.lowLatencyFeature ?: false,
isTv,
initialSettings.presentPriorityWire(),
initialSettings.smoothBuffer,
// The panel's own refresh — from the mode TABLE (streamPanelFps),
// because display.refreshRate reports a per-uid override, not the
// panel. Fallback: the (possibly lying) live rate.
activity?.streamPanelFps(streamHz)?.takeIf { it > 0 }
?: (runCatching { context.display }.getOrNull()?.refreshRate ?: 0f)
.roundToInt(),
)
NativeBridge.nativeStartAudio(handle, lowLatencyMode)
if (micWanted) NativeBridge.nativeStartMic(handle)
@@ -508,6 +635,7 @@ fun StreamScreen(session: ActiveSession, onDisconnect: () -> Unit) {
stats?.let {
StatsOverlay(
it, statsVerbosity, decoderLabel, codecLabel, session.profileName,
panelHz,
Modifier.align(Alignment.TopStart).padding(12.dp),
)
}
@@ -14,7 +14,11 @@ private const val PEN_TOUCHING = 2f
private const val PEN_BARREL1 = 4f
private const val PEN_BARREL2 = 8f
private const val STRIDE = 10
private const val MAX_SAMPLES = 8
// Ceiling on samples per emit, NOT the wire batch size: the JNI layer splits an over-8 run into
// consecutive wire batches (never truncates — a long historical run means the UI thread hitched,
// which is exactly when dropping its head would notch the stroke). 64 samples ≈ >250 ms of
// 240 Hz history; anything past that clamp is a pathological stall, not stroke geometry.
private const val MAX_SAMPLES = 64
/**
* Android stylus the state-full pen plane (design/pen-tablet-input.md §7): pressure, tilt
@@ -117,7 +121,8 @@ internal class StylusStream(private val handle: Long) {
NativeBridge.nativeSendPen(handle, last, 1)
}
/** Historical (coalesced) samples oldest-first, then the current one — a single batch. */
/** Historical (coalesced) samples oldest-first, then the current one one emit; the JNI
* layer splits runs longer than the wire's 8-sample batch cap into consecutive sends. */
private fun emitSamples(me: MotionEvent, idx: Int, size: IntSize) {
val history = minOf(me.historySize, MAX_SAMPLES - 1)
var count = 0
@@ -6,6 +6,7 @@ import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.dp
import io.unom.punktfunk.kit.discovery.osIconTokens
import kotlin.math.max
/**
* The host card's OS marks, resolved from the host's OS-identity chain (mDNS `os` TXT,
@@ -14,18 +15,19 @@ import io.unom.punktfunk.kit.discovery.osIconTokens
* ship, so an unknown distro degrades to its family's mark and finally to Tux; null means
* "no icon", rendering the card exactly as before the field existed.
*
* Path data is vendored from the assets/os-icons masters (Font Awesome Free brands
* CC BY 4.0 + Simple Icons CC0 provenance in that directory's README); Material ships
* no brand icons. Hand-kept as raw SVG path strings (one line each) rather than
* transcribed ImageVector DSL [PathParser] builds the vector once, then it's cached.
* Path data is vendored from the assets/os-icons masters (per-mark provenance and licensing
* in that directory's README; `bash scripts/gen-os-icons.sh <token>` prints a master's
* viewport + path ready to paste); Material ships no brand icons. Hand-kept as raw SVG path
* strings (one line each) rather than transcribed ImageVector DSL [PathParser] builds the
* vector once, then it's cached.
*/
private class OsGlyph(val viewportWidth: Float, val viewportHeight: Float, val d: String)
private val GLYPHS: Map<String, OsGlyph> = mapOf(
"windows" to OsGlyph(
viewportWidth = 448f,
viewportHeight = 512f,
d = "M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z",
viewportWidth = 24f,
viewportHeight = 24f,
d = "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z",
),
"apple" to OsGlyph(
viewportWidth = 384f,
@@ -72,8 +74,28 @@ private val GLYPHS: Map<String, OsGlyph> = mapOf(
viewportHeight = 512f,
d = "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z",
),
// The gaming distros get their own mark rather than their family's: "a Bazzite box" and
// "a Fedora box" are different machines to the person reading the card.
"bazzite" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z",
),
"cachyos" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81",
),
"nobara" to OsGlyph(
viewportWidth = 24f,
viewportHeight = 24f,
d = "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z",
),
)
/** Longest edge of a built mark, in dp — the box callers size the [Icon] to. */
private const val GLYPH_DP = 24f
private val built = mutableMapOf<String, ImageVector>()
/** The mark for a chain, or null (no icon). Vectors build lazily and cache per token. */
@@ -82,11 +104,18 @@ fun resolveOsIcon(chain: String): ImageVector? =
GLYPHS[token]?.let { glyph -> built.getOrPut(token) { glyph.build(token) } }
}
private fun OsGlyph.build(token: String): ImageVector =
ImageVector.Builder(
private fun OsGlyph.build(token: String): ImageVector {
// The intrinsic size has to carry the VIEWPORT'S ASPECT RATIO, not a fixed square:
// a VectorPainter maps the viewport onto the default size with independent x and y
// scales, so declaring a 448x512 mark as 24x24 dp stretches it horizontally — which is
// exactly how Tux and the Apple mark used to come out on a phone. Scaling the longest
// edge to GLYPH_DP instead keeps the ratio, and Icon() paints with ContentScale.Fit, so
// the mark letterboxes inside whatever box the caller sized us to.
val longest = max(viewportWidth, viewportHeight)
return ImageVector.Builder(
name = "OsIcon.$token",
defaultWidth = 24.dp,
defaultHeight = 24.dp,
defaultWidth = (GLYPH_DP * viewportWidth / longest).dp,
defaultHeight = (GLYPH_DP * viewportHeight / longest).dp,
viewportWidth = viewportWidth,
viewportHeight = viewportHeight,
).apply {
@@ -96,3 +125,4 @@ private fun OsGlyph.build(token: String): ImageVector =
fill = SolidColor(Color.Black),
)
}.build()
}
@@ -0,0 +1,72 @@
package io.unom.punktfunk
import io.unom.punktfunk.components.resolveOsIcon
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Pure JVM test of the host card's OS marks (`components/OsIcons.kt`). Run:
* `./gradlew :app:testDebugUnitTest`.
*
* The aspect assertions are the point: a [androidx.compose.ui.graphics.vector.VectorPainter] maps
* the viewport onto the vector's default size with independent x and y scales, so a mark whose
* default size does not carry its viewport's ratio renders STRETCHED silently, with no crash and
* no warning. That is exactly how Tux and the Apple mark used to look on a phone.
*/
class OsIconsTest {
/** `ImageVector.name` is "OsIcon.<token>" — the only handle on which mark got resolved. */
private fun markOf(chain: String) = resolveOsIcon(chain)?.name?.removePrefix("OsIcon.")
@Test
fun defaultSizeCarriesTheViewportAspect() {
for (chain in listOf("windows", "linux", "opensuse", "steam", "apple", "bazzite")) {
val v = resolveOsIcon(chain)
assertNotNull("no mark for $chain", v)
v!!
assertEquals(
"$chain default size must keep the viewport ratio",
v.viewportWidth / v.viewportHeight,
v.defaultWidth.value / v.defaultHeight.value,
0.001f,
)
assertEquals(
"$chain longest edge must be the 24dp box",
24f,
maxOf(v.defaultWidth.value, v.defaultHeight.value),
0.001f,
)
}
}
@Test
fun tallMarkIsNarrowerThanItsBox() {
// Tux is 448x512, so a correct build is 21x24dp — 24x24 would be the stretched bug.
val tux = resolveOsIcon("linux")!!
assertEquals(21f, tux.defaultWidth.value, 0.001f)
assertEquals(24f, tux.defaultHeight.value, 0.001f)
}
@Test
fun gamingDistrosResolveToTheirOwnMark() {
// The whole reason these three ship art: without it they'd draw their family's mark.
assertEquals("bazzite", markOf("linux/fedora/bazzite"))
assertEquals("cachyos", markOf("linux/arch/cachyos"))
assertEquals("nobara", markOf("linux/rhel/nobara"))
}
@Test
fun unknownDistroStillDegradesThroughItsFamily() {
assertEquals("fedora", markOf("linux/fedora/somethingnew"))
assertEquals("linux", markOf("linux/frontier/chimera"))
assertEquals("steam", markOf("linux/arch/steamos")) // brand alias
assertEquals("apple", markOf("macos"))
}
@Test
fun noChainMeansNoMark() {
assertNull(resolveOsIcon(""))
assertNull(resolveOsIcon("!!!"))
}
}
@@ -366,6 +366,8 @@ internal fun StreamScene(verbosity: StatsVerbosity = StatsVerbosity.DETAILED) {
10.0, 9.0, 16.0, 1.0, 0.9, 0.4, 0.6, 0.3,
2.0, 1.0, 5.0, 238.0,
1.0, 0.5, 1.8, 2.6,
// Timeline-presenter split: pace + latch tile the display term; presents ≈ fps.
0.2, 0.3, 236.0, 1.0,
),
verbosity = verbosity,
decoderLabel = "c2.qti.hevc.decoder · low-latency",
@@ -0,0 +1,301 @@
package io.unom.punktfunk.kit
import android.content.Context
import android.hardware.usb.UsbDevice
import android.os.Handler
import android.os.Looper
import android.util.Log
import android.view.InputDevice
/**
* One captured Sony pad (DualSense / DualSense Edge / DualShock 4) over USB stream mode only.
* The capture exists to fix what the InputDevice path structurally can't: rumble depends on the
* phone's kernel exposing force feedback (many don't), and adaptive triggers / lightbar / player
* LEDs have NO platform API at all. Claiming the pad's HID interface makes all of it work on any
* phone, plus gyro + touchpad the standard path never captured.
*
* Unlike [Sc2Capture] there is no raw passthrough the host's DualSense/DS4 backends consume
* only typed events and no UI mode: an UNcaptured Sony pad is a perfectly good InputDevice, so
* outside a stream the ordinary path drives the console UI and this class isn't constructed.
* That also makes the InputDevice path the automatic fallback whenever the capture doesn't
* engage (toggle off, permission denied, Bluetooth).
*
* Input: parse ([DsDevice.parseState]) typed mirror on an [GamepadRouter.ExternalPad] (buttons
* diffed, axes on-change the exit chord participates like any pad) + the rich plane (touch
* normalized to the wire's 0..65535 screen space on-change; motion forwarded per report in raw
* device units, the wire's contract). The wire slot is claimed lazily on the FIRST parsed report
* and freed on unplug/[stop], so indices never leak.
*
* Feedback: implements [GamepadFeedback.PadFeedbackSink] rumble / trigger / lightbar / player
* LED events addressed to this pad's wire index become USB output reports on the physical pad
* ([DsDevice] builders). Rendering runs on the feedback poll threads; [HidUsbLink.writeRaw] is
* thread-safe (bounded newest-wins queue, submitted by the reader thread). A USB pad holds its
* rumble level until written zero, so a backstop timer re-arms per command and writes the stop
* itself if the poll thread stalls the engine's explicit zeros remain the real stop mechanism.
*/
class DsCapture(
context: Context,
private val router: GamepadRouter,
) : GamepadFeedback.PadFeedbackSink {
private val usb = HidUsbLink(
context,
HidUsbLink.Config(
tag = TAG,
threadName = "pf-ds-usb",
deviceMatch = { it.vendorId == DsDevice.VID_SONY && it.productId in DsDevice.USB_PIDS },
// No ifaceFilter: the pad's audio interfaces are not HID class, so the link's built-in
// class check already leaves them (and the pad's headset routing) to Android; the
// single HID interface is the only claim.
),
::onReport,
::onLinkClosed,
)
@Volatile private var model: DsDevice.Model? = null
@Volatile private var pad: GamepadRouter.ExternalPad? = null
// Typed-mirror diff state (wire units) + rich-plane on-change mirrors. Link thread only.
private val state = DsDevice.State()
private var wireButtons = 0
private val lastAxis = IntArray(6) { Int.MIN_VALUE }
private val lastTouchActive = BooleanArray(2)
private val lastTouchX = IntArray(2) { -1 }
private val lastTouchY = IntArray(2) { -1 }
// DS4 composed feedback (its writes are full-state — see DsDevice.ds4Report). Feedback threads.
// The lightbar starts at hid-sony's player-1 blue so the first composed write (usually a
// rumble, before any host Led lands) doesn't black the bar out.
@Volatile private var ds4Low = 0
@Volatile private var ds4High = 0
@Volatile private var ds4Rgb = 0x000040
// Rumble backstop: a USB pad holds its level until told zero, so a stalled poll thread would
// leave the motors running — re-armed per command, cancelled by an explicit (0,0).
private val mainHandler = Handler(Looper.getMainLooper())
@Volatile private var backstop: Runnable? = null
/** Fired (link thread) when the capture engages or drops — the Controllers screen's status. */
@Volatile
var onActiveChanged: ((active: Boolean) -> Unit)? = null
val isActive: Boolean get() = model != null
/** First attached Sony USB pad, for the permission flow. Needs no permission to enumerate. */
fun findUsbDevice(): UsbDevice? = usb.findDevice()
/**
* Start capturing [dev] (permission already granted). Claims the HID interface the kernel
* driver detaches and the pad's InputDevice node vanishes; its router slot (if the router
* already opened one from the pre-claim InputDevice) is released HERE, at claim time, rather
* than waiting for the system's removal callback so the freed wire index is deterministic
* for this capture's ExternalPad instead of racing the first report against the callback. A
* released sibling that still exists as an InputDevice (a same-model Bluetooth pad) lazily
* reopens a slot on its next input event, so over-matching self-heals.
*/
fun startUsb(dev: UsbDevice): Boolean {
if (model != null) return false
val m = DsDevice.modelFor(dev.productId) ?: return false
if (!usb.start(dev)) return false
model = m
for (id in InputDevice.getDeviceIds()) {
val d = InputDevice.getDevice(id) ?: continue
if (d.vendorId == dev.vendorId && d.productId == dev.productId) router.releaseDevice(id)
}
// Release the firmware's lightbar animation once so host lightbar writes take effect
// (the same init hid-playstation/SDL send on open).
if (m != DsDevice.Model.DUALSHOCK4) usb.writeRaw(0, DsDevice.ds5InitReport(m))
Log.i(TAG, "Sony pad captured over USB: PID=0x%04x model=%s".format(dev.productId, m))
onActiveChanged?.invoke(true)
return true
}
/** Stop the link and free the wire slot (host tears the virtual pad down). Idempotent. */
fun stop() {
val m = model
if (m != null) {
// The interfaces are about to release with the kernel driver still detached — a
// mid-rumble teardown would leave the motors running with nobody to stop them.
// EP0-direct (the reader thread is stopping; the queue would never drain).
usb.writeControl(stopReport(m))
}
disarmBackstop()
usb.stop()
val wasActive = model != null
model = null
releaseSlot()
if (wasActive) onActiveChanged?.invoke(false)
}
// ---- link callbacks (link thread) ----
private fun onReport(report: ByteArray, len: Int) {
val m = model ?: return
if (!DsDevice.parseState(m, report, len, state)) return
val p = pad ?: router.openExternal(m.pref)?.also {
pad = it
Log.i(TAG, "captured $m → wire pad ${it.index}")
} ?: return // all 16 wire indices taken — drop until one frees
mirrorTyped(p)
mirrorRich(p, m)
}
private fun onLinkClosed() {
Log.i(TAG, "Sony USB link closed (unplug)")
disarmBackstop()
val wasActive = model != null
model = null
releaseSlot()
if (wasActive) onActiveChanged?.invoke(false)
}
/** Diff the parsed state onto the per-transition plane (buttons + axes, on change only). */
private fun mirrorTyped(p: GamepadRouter.ExternalPad) {
var changed = state.buttons xor wireButtons
while (changed != 0) {
val bit = changed and -changed // lowest changed bit
p.button(bit, state.buttons and bit != 0)
changed = changed and bit.inv()
}
wireButtons = state.buttons
axis(p, Gamepad.AXIS_LS_X, state.lsX)
axis(p, Gamepad.AXIS_LS_Y, state.lsY)
axis(p, Gamepad.AXIS_RS_X, state.rsX)
axis(p, Gamepad.AXIS_RS_Y, state.rsY)
axis(p, Gamepad.AXIS_LT, state.lt)
axis(p, Gamepad.AXIS_RT, state.rt)
}
private fun axis(p: GamepadRouter.ExternalPad, id: Int, v: Int) {
if (lastAxis[id] == v) return
lastAxis[id] = v
p.axis(id, v)
}
/**
* The rich plane: touch contacts normalized to the wire's 0..65535 screen space, forwarded
* on change per slot; motion forwarded every report (raw device units the wire is a unit
* passthrough into the host's virtual pad, and sensor noise makes per-report dedup pointless).
*/
private fun mirrorRich(p: GamepadRouter.ExternalPad, m: DsDevice.Model) {
for (f in 0 until 2) {
if (state.touchActive[f]) {
val x = (state.touchX[f].coerceIn(0, m.touchW - 1) * 65535) / (m.touchW - 1)
val y = (state.touchY[f].coerceIn(0, m.touchH - 1) * 65535) / (m.touchH - 1)
if (!lastTouchActive[f] || x != lastTouchX[f] || y != lastTouchY[f]) {
p.touch(f, true, x, y)
lastTouchActive[f] = true
lastTouchX[f] = x
lastTouchY[f] = y
}
} else if (lastTouchActive[f]) {
p.touch(f, false, lastTouchX[f], lastTouchY[f])
lastTouchActive[f] = false
}
}
p.motion(state.gyro, state.accel)
}
private fun releaseSlot() {
// Lift any still-touching finger so the host's virtual touchpad doesn't hold a contact.
val p = pad
if (p != null) {
for (f in 0 until 2) if (lastTouchActive[f]) p.touch(f, false, lastTouchX[f], lastTouchY[f])
}
p?.close()
pad = null
wireButtons = 0
lastAxis.fill(Int.MIN_VALUE)
lastTouchActive.fill(false)
lastTouchX.fill(-1)
lastTouchY.fill(-1)
}
// ---- PadFeedbackSink (feedback poll threads) ----
override fun ownsPad(pad: Int): Boolean = pad == this.pad?.index
override fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long) {
val m = model ?: return
if (low == 0 && high == 0) {
disarmBackstop()
} else {
armBackstop(backstopMs)
}
if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = low
ds4High = high
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5RumbleReport(m, low, high))
}
}
override fun led(pad: Int, r: Int, g: Int, b: Int) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) {
ds4Rgb = (r shl 16) or (g shl 8) or b
writeDs4()
} else {
usb.writeRaw(0, DsDevice.ds5LightbarReport(m, r, g, b))
}
}
override fun playerLeds(pad: Int, bits: Int) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no player LEDs on a DS4 (host never sends any)
usb.writeRaw(0, DsDevice.ds5PlayerLedsReport(m, bits))
}
override fun trigger(pad: Int, which: Int, effect: ByteArray) {
val m = model ?: return
if (m == DsDevice.Model.DUALSHOCK4) return // no adaptive triggers on a DS4
usb.writeRaw(0, DsDevice.ds5TriggerReport(m, which, effect))
}
private fun writeDs4() = usb.writeRaw(
0,
DsDevice.ds4Report(
ds4Low,
ds4High,
(ds4Rgb shr 16) and 0xFF,
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
),
)
/** The report that stops the motors. The DS4's is a full-state write, so it zeroes the
* composed motor state and carries the current lightbar rather than blacking it out. */
private fun stopReport(m: DsDevice.Model): ByteArray = if (m == DsDevice.Model.DUALSHOCK4) {
ds4Low = 0
ds4High = 0
DsDevice.ds4Report(
0,
0,
(ds4Rgb shr 16) and 0xFF,
(ds4Rgb shr 8) and 0xFF,
ds4Rgb and 0xFF,
)
} else {
DsDevice.ds5RumbleReport(m, 0, 0)
}
/** (Re)arm the stalled-poll-thread net: write a rumble stop at the command's backstop. */
private fun armBackstop(ms: Long) {
backstop?.let { mainHandler.removeCallbacks(it) }
val r = Runnable {
backstop = null
model?.let { usb.writeRaw(0, stopReport(it)) }
}
backstop = r
mainHandler.postDelayed(r, ms.coerceAtLeast(1))
}
private fun disarmBackstop() {
backstop?.let { mainHandler.removeCallbacks(it) }
backstop = null
}
private companion object {
const val TAG = "DsCapture"
}
}
@@ -0,0 +1,340 @@
package io.unom.punktfunk.kit
/**
* Sony DualSense / DualSense Edge / DualShock 4 **USB** protocol constants: the input-report
* parser and the output-report builders the capture link ([DsCapture]) needs. Unlike the SC2's
* as-is passthrough, nothing rides the wire raw here the host's DualSense/DS4 backends consume
* only typed events (`dualsense_proto.rs` discards `RichInput::HidReport`), so the client parses
* the pad's input reports into the ordinary button/axis wire + the rich touch/motion plane, and
* renders the host's feedback (rumble / adaptive triggers / lightbar / player LEDs) by composing
* USB output reports itself.
*
* Protocol ground truth: the Linux kernel's `hid-playstation` / `hid-sony` structs, SDL's
* `SDL_hidapi_ps5.c` / `SDL_hidapi_ps4.c`, mirrored host-side in `punktfunk-host`'s
* `dualsense_proto.rs` / `dualshock4_proto.rs` this file is the byte-exact inverse of those
* serializers (offsets cross-referenced below). USB only: over Bluetooth the reports shift
* (`0x31` + CRC32) AND Android exposes no raw path to a Classic pad anyway, so the BT case never
* reaches this code an uncaptured pad stays on the ordinary InputDevice path.
*/
object DsDevice {
const val VID_SONY = 0x054C
const val PID_DUALSENSE = 0x0CE6
const val PID_DUALSENSE_EDGE = 0x0DF2
const val PID_DUALSHOCK4_V1 = 0x05C4
const val PID_DUALSHOCK4_V2 = 0x09CC
val USB_PIDS = setOf(PID_DUALSENSE, PID_DUALSENSE_EDGE, PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2)
/**
* One captured model: its `GamepadPref` wire byte (the virtual pad the host builds matching
* the physical one), its output-report size (the descriptor-declared size the firmware
* expects: DS5 48 = id + 47, Edge 64 = id + 63, DS4 32 = id + 31), and its touchpad extent
* (`dualsense_proto::DS_TOUCH_W/H`, `dualshock4_proto::DS4_TOUCH_*`) for normalizing touches
* onto the wire's 0..65535 space.
*/
enum class Model(val pref: Int, val outputSize: Int, val touchW: Int, val touchH: Int) {
DUALSENSE(Gamepad.PREF_DUALSENSE, 48, 1920, 1080),
DUALSENSE_EDGE(Gamepad.PREF_DUALSENSEEDGE, 64, 1920, 1080),
DUALSHOCK4(Gamepad.PREF_DUALSHOCK4, 32, 1920, 942),
}
/** The captured [Model] for a USB PID, or null for anything we don't capture. */
fun modelFor(pid: Int): Model? = when (pid) {
PID_DUALSENSE -> Model.DUALSENSE
PID_DUALSENSE_EDGE -> Model.DUALSENSE_EDGE
PID_DUALSHOCK4_V1, PID_DUALSHOCK4_V2 -> Model.DUALSHOCK4
else -> null
}
/**
* The client-consumed fields of one input report. `buttons` is already the WIRE bitmask
* (`Gamepad.BTN_*`) the parse maps device bits straight to the wire, the exact inverse of
* the host's `DsState::from_gamepad` (BTN_A cross, BTN_B circle, BTN_X square,
* BTN_Y triangle; positional, not glyph-order). Gyro/accel stay in raw device units the
* wire's `Motion` is a unit passthrough into the virtual pad's report. Touch coordinates stay
* device-raw here; [DsCapture] normalizes against the model's extent when forwarding.
*/
class State {
var buttons = 0
var lsX = 0; var lsY = 0 // wire i16, +y = up (device is +y down — inverted in the parse)
var rsX = 0; var rsY = 0
var lt = 0; var rt = 0 // 0..255
val gyro = IntArray(3) // raw i16 units (pitch/yaw/roll)
val accel = IntArray(3)
val touchActive = BooleanArray(2)
val touchX = IntArray(2) // raw device coords (0..touchW-1 / 0..touchH-1)
val touchY = IntArray(2)
}
// DS5 USB input report 0x01 (64 B) — offsets mirror the host serializer
// (`dualsense_proto.rs::serialize_state`): [1..7) sticks + triggers, [8] hat|face,
// [9]/[10] buttons, [16..28) gyro+accel, [33..41) two 4-byte touch points.
private const val DS5_INPUT_ID = 0x01
// report[8] high nibble (`dualsense_proto::btn0`).
private const val DS5_SQUARE = 0x10
private const val DS5_CROSS = 0x20
private const val DS5_CIRCLE = 0x40
private const val DS5_TRIANGLE = 0x80
// report[9] (`btn1`).
private const val DS5_L1 = 0x01
private const val DS5_R1 = 0x02
private const val DS5_CREATE = 0x10
private const val DS5_OPTIONS = 0x20
private const val DS5_L3 = 0x40
private const val DS5_R3 = 0x80
// report[10] (`btn2`); the FN/BACK bits exist only on the Edge.
private const val DS5_PS = 0x01
private const val DS5_TOUCHPAD = 0x02
private const val DS5_MUTE = 0x04
private const val EDGE_FN_LEFT = 0x10
private const val EDGE_FN_RIGHT = 0x20
private const val EDGE_BACK_LEFT = 0x40
private const val EDGE_BACK_RIGHT = 0x80
// DS4 USB input report 0x01 (64 B) — offsets mirror `dualshock4_proto.rs::serialize_state`:
// [1..5) sticks, [5] hat|face, [6]/[7] buttons, [8]/[9] triggers, [13..25) gyro+accel,
// [35..43) two touch points (same 4-byte packing as the DS5).
private const val DS4_L1 = 0x01
private const val DS4_R1 = 0x02
private const val DS4_SHARE = 0x10
private const val DS4_OPTIONS = 0x20
private const val DS4_L3 = 0x40
private const val DS4_R3 = 0x80
private const val DS4_PS = 0x01
private const val DS4_TOUCHPAD = 0x02
/**
* Parse one USB input report (`0x01`) into [out]. Returns false for any other report id or a
* short read (the pad also emits `0x09`-family getMAC responses etc. on EP0 those never hit
* the interrupt endpoint, but be defensive). Motion/touch fields update only when the report
* is long enough to carry them (it always is on glass 64-byte interrupt transfers).
*/
fun parseState(model: Model, report: ByteArray, len: Int, out: State): Boolean =
if (model == Model.DUALSHOCK4) {
parseDs4(report, len, out)
} else {
parseDs5(model, report, len, out)
}
private fun parseDs5(model: Model, r: ByteArray, len: Int, out: State): Boolean {
if (len < 11 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
out.rsX = stickX(u8(r, 3))
out.rsY = stickY(u8(r, 4))
out.lt = u8(r, 5)
out.rt = u8(r, 6)
val b8 = u8(r, 8)
val b9 = u8(r, 9)
val b10 = u8(r, 10)
var w = hatBits(b8 and 0x0F)
if (b8 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
if (b8 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
if (b8 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
if (b8 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
if (b9 and DS5_L1 != 0) w = w or Gamepad.BTN_LB
if (b9 and DS5_R1 != 0) w = w or Gamepad.BTN_RB
// L2/R2 digital bits ride the analog axes instead (wire convention).
if (b9 and DS5_CREATE != 0) w = w or Gamepad.BTN_BACK
if (b9 and DS5_OPTIONS != 0) w = w or Gamepad.BTN_START
if (b9 and DS5_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
if (b9 and DS5_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
if (b10 and DS5_PS != 0) w = w or Gamepad.BTN_GUIDE
if (b10 and DS5_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
if (b10 and DS5_MUTE != 0) w = w or Gamepad.BTN_MISC1
if (model == Model.DUALSENSE_EDGE) {
// Wire paddle order matches the host's `edge_paddle_bits` inverse: PADDLE1/2 =
// right/left BACK (the primary pair, Steam R4/L4 convention), PADDLE3/4 = right/left Fn.
if (b10 and EDGE_BACK_RIGHT != 0) w = w or Gamepad.BTN_PADDLE1
if (b10 and EDGE_BACK_LEFT != 0) w = w or Gamepad.BTN_PADDLE2
if (b10 and EDGE_FN_RIGHT != 0) w = w or Gamepad.BTN_PADDLE3
if (b10 and EDGE_FN_LEFT != 0) w = w or Gamepad.BTN_PADDLE4
}
out.buttons = w
if (len >= 28) {
for (i in 0 until 3) out.gyro[i] = i16(r, 16 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 22 + 2 * i)
}
if (len >= 41) {
unpackTouch(r, 33, out, 0)
unpackTouch(r, 37, out, 1)
}
return true
}
private fun parseDs4(r: ByteArray, len: Int, out: State): Boolean {
if (len < 10 || (r[0].toInt() and 0xFF) != DS5_INPUT_ID) return false // DS4 shares id 0x01
out.lsX = stickX(u8(r, 1))
out.lsY = stickY(u8(r, 2))
out.rsX = stickX(u8(r, 3))
out.rsY = stickY(u8(r, 4))
val b5 = u8(r, 5)
val b6 = u8(r, 6)
val b7 = u8(r, 7)
out.lt = u8(r, 8)
out.rt = u8(r, 9)
var w = hatBits(b5 and 0x0F)
if (b5 and DS5_CROSS != 0) w = w or Gamepad.BTN_A
if (b5 and DS5_CIRCLE != 0) w = w or Gamepad.BTN_B
if (b5 and DS5_SQUARE != 0) w = w or Gamepad.BTN_X
if (b5 and DS5_TRIANGLE != 0) w = w or Gamepad.BTN_Y
if (b6 and DS4_L1 != 0) w = w or Gamepad.BTN_LB
if (b6 and DS4_R1 != 0) w = w or Gamepad.BTN_RB
if (b6 and DS4_SHARE != 0) w = w or Gamepad.BTN_BACK
if (b6 and DS4_OPTIONS != 0) w = w or Gamepad.BTN_START
if (b6 and DS4_L3 != 0) w = w or Gamepad.BTN_LS_CLICK
if (b6 and DS4_R3 != 0) w = w or Gamepad.BTN_RS_CLICK
if (b7 and DS4_PS != 0) w = w or Gamepad.BTN_GUIDE
if (b7 and DS4_TOUCHPAD != 0) w = w or Gamepad.BTN_TOUCHPAD
out.buttons = w
if (len >= 25) {
for (i in 0 until 3) out.gyro[i] = i16(r, 13 + 2 * i)
for (i in 0 until 3) out.accel[i] = i16(r, 19 + 2 * i)
}
if (len >= 43) {
unpackTouch(r, 35, out, 0)
unpackTouch(r, 39, out, 1)
}
return true
}
/** hat nibble (0=N … 7=NW, 8+=neutral) → wire dpad bits — inverse of the host's `hat()`. */
private fun hatBits(h: Int): Int = when (h) {
0 -> Gamepad.BTN_DPAD_UP
1 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT
2 -> Gamepad.BTN_DPAD_RIGHT
3 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_RIGHT
4 -> Gamepad.BTN_DPAD_DOWN
5 -> Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_DPAD_LEFT
6 -> Gamepad.BTN_DPAD_LEFT
7 -> Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_LEFT
else -> 0
}
/**
* One 4-byte touch point (shared DS5/DS4 packing `dualsense_proto::pack_touch`): byte0
* bit7 = NOT active + contact id in bits 0..6; 12-bit x/y split across bytes 1..3.
*/
private fun unpackTouch(r: ByteArray, o: Int, out: State, slot: Int) {
val b0 = u8(r, o)
out.touchActive[slot] = b0 and 0x80 == 0
out.touchX[slot] = u8(r, o + 1) or ((u8(r, o + 2) and 0x0F) shl 8)
out.touchY[slot] = (u8(r, o + 2) shr 4) or (u8(r, o + 3) shl 4)
}
private fun u8(r: ByteArray, o: Int): Int = r[o].toInt() and 0xFF
private fun i16(r: ByteArray, o: Int): Int =
((r[o + 1].toInt() shl 8) or (r[o].toInt() and 0xFF)).toShort().toInt()
// Device stick byte (0..255, centre 0x80, +y down) → wire i16 (+y up) — the exact inverse of
// the host's `to_u8` mapping (`lx = to_u8(x)`, `ly = 255 - to_u8(y)`).
private fun stickX(raw: Int): Int = raw * 257 - 32768
private fun stickY(raw: Int): Int = (255 - raw) * 257 - 32768
// ---- Output reports ----
//
// Every write is valid-flag-selective: only the flagged channel applies, the firmware keeps
// the rest (the same contract the host's `parse_ds_output` mirrors — an unflagged parse would
// turn every rumble into a lightbar-off). The DS4 is the exception: its builder writes the
// full composed motors+LED state each time with both flags, SDL's proven-on-hardware shape.
// DS5 output report 0x02, report-relative offsets (`dualsense_proto::parse_ds_output`):
// [1] valid_flag0 (bit0 compat vibration, bit1 haptics select, bit2 R2 block, bit3 L2 block),
// [2] valid_flag1 (bit2 lightbar, bit4 player LEDs), [3]/[4] motors, [11..22) R2 effect,
// [22..33) L2 effect, [39] valid_flag2 (bit1 lightbar-setup enable, bit2 vibration2),
// [42] lightbar_setup, [44] player LEDs, [45..48) RGB.
private const val DS5_FLAG0_COMPAT_VIBRATION = 0x01
private const val DS5_FLAG0_HAPTICS_SELECT = 0x02
private const val DS5_FLAG0_R2_EFFECT = 0x04
private const val DS5_FLAG0_L2_EFFECT = 0x08
private const val DS5_FLAG1_LIGHTBAR = 0x04
private const val DS5_FLAG1_PLAYER_LEDS = 0x10
private const val DS5_FLAG2_LIGHTBAR_SETUP = 0x02
private const val DS5_FLAG2_VIBRATION2 = 0x04
private const val DS5_LIGHTBAR_SETUP_LIGHT_OUT = 0x02
/** The 11-byte adaptive-trigger effect block length (mode byte + 10 parameters). */
const val TRIGGER_EFFECT_LEN = 11
private fun newDs5(model: Model): ByteArray = ByteArray(model.outputSize).also { it[0] = 0x02 }
/**
* One-time capture-start report (DS5/Edge): release the firmware's lightbar animation
* (`LIGHTBAR_SETUP_LIGHT_OUT`) so subsequent host lightbar writes take effect the same
* init both hid-playstation and SDL send on open. No-op fields otherwise.
*/
fun ds5InitReport(model: Model): ByteArray = newDs5(model).also {
it[39] = DS5_FLAG2_LIGHTBAR_SETUP.toByte()
it[42] = DS5_LIGHTBAR_SETUP_LIGHT_OUT.toByte()
}
/**
* DS5/Edge rumble at the wire's u16 amplitudes ([low] = heavy/left motor, [high] =
* light/right the host parses `[3]` as high and `[4]` as low, mirrored here). Flags both
* the classic compat-vibration path AND `VIBRATION2` (firmware 2.24's full-range replot;
* older firmware ignores the unknown flag2 bit) the host parser accepts either.
*/
fun ds5RumbleReport(model: Model, low: Int, high: Int): ByteArray = newDs5(model).also {
it[1] = (DS5_FLAG0_COMPAT_VIBRATION or DS5_FLAG0_HAPTICS_SELECT).toByte()
it[39] = DS5_FLAG2_VIBRATION2.toByte()
it[3] = amp8(high).toByte()
it[4] = amp8(low).toByte()
}
/**
* DS5/Edge adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] is the raw 11-byte
* trigger block from the wire (`HidOutput::Trigger` the game's bytes verbatim), copied to
* the same offsets the host parsed it from ([11..22) R2 / [22..33) L2).
*/
fun ds5TriggerReport(model: Model, which: Int, effect: ByteArray): ByteArray = newDs5(model).also {
val at = if (which == 1) 11 else 22
it[1] = (if (which == 1) DS5_FLAG0_R2_EFFECT else DS5_FLAG0_L2_EFFECT).toByte()
val n = effect.size.coerceAtMost(TRIGGER_EFFECT_LEN)
System.arraycopy(effect, 0, it, at, n)
}
/** DS5/Edge lightbar RGB. */
fun ds5LightbarReport(model: Model, r: Int, g: Int, b: Int): ByteArray = newDs5(model).also {
it[2] = DS5_FLAG1_LIGHTBAR.toByte()
it[45] = r.toByte()
it[46] = g.toByte()
it[47] = b.toByte()
}
/** DS5/Edge player-indicator LEDs (low 5 bits, hid-playstation pattern). */
fun ds5PlayerLedsReport(model: Model, bits: Int): ByteArray = newDs5(model).also {
it[2] = DS5_FLAG1_PLAYER_LEDS.toByte()
it[44] = (bits and 0x1F).toByte()
}
// DS4 output report 0x05 (32 B), report-relative (`dualshock4_proto::parse_ds4_output`):
// [1] valid_flag0 (bit0 motors, bit1 LED, bit2 blink), [4] weak/right motor, [5] strong/left,
// [6..9) RGB, [9]/[10] blink on/off.
private const val DS4_FLAG0_MOTORS = 0x01
private const val DS4_FLAG0_LED = 0x02
/**
* One full-state DS4 write: motors + lightbar together, both flags set the composed-state
* shape SDL uses against real hardware (per-channel selective writes are unproven on DS4
* firmware, unlike the DS5's). [DsCapture] holds the composition. Blink stays untouched.
*/
fun ds4Report(low: Int, high: Int, r: Int, g: Int, b: Int): ByteArray =
ByteArray(Model.DUALSHOCK4.outputSize).also {
it[0] = 0x05
it[1] = (DS4_FLAG0_MOTORS or DS4_FLAG0_LED).toByte()
it[4] = amp8(high).toByte()
it[5] = amp8(low).toByte()
it[6] = r.toByte()
it[7] = g.toByte()
it[8] = b.toByte()
}
// Wire u16 amplitude → motor byte; a nonzero command never collapses to 0 (parity with the
// vibrator path's toAmplitude).
private fun amp8(v16: Int): Int {
val a = (v16 ushr 8) and 0xFF
return if (v16 != 0 && a == 0) 1 else a
}
}
@@ -46,6 +46,42 @@ class GamepadFeedback(
private val router: GamepadRouter?,
private val deviceVibrator: Vibrator? = null,
) {
/**
* A capture link's feedback renderer for the wire pads it owns, consulted BEFORE the
* InputDevice vibrator/lights paths. A captured controller has no [android.view.InputDevice]
* (its slot is an [GamepadRouter.ExternalPad] on a synthetic id, so [GamepadRouter.deviceForPad]
* resolves null and the platform paths no-op) the link renders instead, by composing USB
* output reports on the physical pad. This is also the ONLY route to adaptive triggers:
* Android has no platform API for them, so without a sink a Trigger event is log-and-drop.
* Invoked on the feedback poll threads; implementations must be thread-safe.
*/
interface PadFeedbackSink {
/** True when this sink renders feedback for wire pad [pad]; the render methods are only
* invoked while true. Racing a pad close is fine a late render is a harmless no-op. */
fun ownsPad(pad: Int): Boolean
/** One effective rumble command (`(0,0)` = stop now; else a one-shot at this level with
* [backstopMs] as the self-termination net see [GamepadFeedback.renderRumble]). */
fun rumble(pad: Int, low: Int, high: Int, backstopMs: Long)
/** Lightbar RGB. */
fun led(pad: Int, r: Int, g: Int, b: Int)
/** Player-indicator LED bitmask (low 5 bits, hid-playstation layout). */
fun playerLeds(pad: Int, bits: Int)
/** One adaptive-trigger effect: [which] 0 = L2, 1 = R2; [effect] = the raw DS5 trigger
* block (mode byte + parameters) exactly as the game wrote it host-side. */
fun trigger(pad: Int, which: Int, effect: ByteArray)
}
/**
* The active capture link's sink (a [DsCapture]), or null. Wired by StreamScreen alongside
* [onHidRaw]; cleared before the poll threads stop.
*/
@Volatile
var sink: PadFeedbackSink? = null
private companion object {
const val TAG = "pf.feedback"
const val TAG_LED: Byte = 0x01
@@ -221,6 +257,12 @@ class GamepadFeedback(
// controller 1 unconditionally rather than only motor-less pads — capability probing
// already decided the bind, and the user opted in.
if (pad == 0) renderDeviceRumble(low, high, durationMs)
// A captured pad's link renders on the physical controller itself (its slot has no
// InputDevice, so the vibrator bind below would resolve null and drop the command).
sink?.takeIf { it.ownsPad(pad) }?.let {
it.rumble(pad, low, high, durationMs)
return
}
val bind = rumbleBindFor(pad) ?: return
val lo = toAmplitude(low)
val hi = toAmplitude(high)
@@ -313,23 +355,36 @@ class GamepadFeedback(
val g = buf.get().toInt() and 0xFF
val b = buf.get().toInt() and 0xFF
Log.i(TAG, "hidout pad=$pad Led r=$r g=$g b=$b") // verification line
if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null) s.led(pad, r, g, b)
else if (Build.VERSION.SDK_INT >= 33) setLightbar(pad, Color.rgb(r, g, b))
}
TAG_PLAYER_LEDS -> {
val bits = buf.get().toInt() and 0x1F
val player = playerIndexForBits(bits)
Log.i(TAG, "hidout pad=$pad PlayerLeds bits=$bits player=$player") // verification line
if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null) s.playerLeds(pad, bits)
else if (Build.VERSION.SDK_INT >= 33) setPlayerId(pad, player)
}
TAG_TRIGGER -> {
val which = buf.get().toInt() and 0xFF // 0 = L2, 1 = R2
val effLen = n - 3 // [pad][kind][which] header, then the effect block
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No public adaptive-trigger API on Android — parse-validate the mode + log only.
Log.i(
TAG,
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (adaptive triggers unsupported on Android)".format(mode),
)
val s = sink?.takeIf { it.ownsPad(pad) }
if (s != null && effLen > 0) {
// A captured DualSense: the raw trigger block replays onto the physical pad.
val effect = ByteArray(effLen)
buf.get(effect)
Log.i(TAG, "hidout pad=$pad Trigger which=$which effLen=$effLen → captured pad") // verification line
s.trigger(pad, which, effect)
} else {
val mode = if (effLen > 0) buf.get().toInt() and 0xFF else 0
// No platform adaptive-trigger API — parse-validate the mode + log only.
Log.i(
TAG,
"hidout pad=$pad Trigger which=$which effLen=$effLen mode=0x%02x (no adaptive-trigger renderer for this pad)".format(mode),
)
}
}
TAG_HID_RAW -> {
// As-is SC2 passthrough: a raw report the host's Steam wrote to the virtual pad —
@@ -210,6 +210,24 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
if (slot != null) NativeBridge.nativeSendPadHidReport(handle, index, buf, len)
}
/** One touchpad contact on the rich plane: [finger] 0/1, x/y normalized 0..65535 in
* SCREEN convention (+y down); `active = false` lifts the finger. On-change only. */
fun touch(finger: Int, active: Boolean, x: Int, y: Int) {
if (slot != null) NativeBridge.nativeSendPadTouch(handle, index, finger, active, x, y)
}
/** One motion sample on the rich plane (gyro pitch/yaw/roll + accel, raw device i16
* units the host passes them straight into the virtual pad's report). Per report. */
fun motion(gyro: IntArray, accel: IntArray) {
if (slot != null) {
NativeBridge.nativeSendPadMotion(
handle, index,
gyro[0], gyro[1], gyro[2],
accel[0], accel[1], accel[2],
)
}
}
/** Flush held state, signal the removal, and free the wire index. Idempotent. */
fun close() = closeSlot(syntheticId)
}
@@ -228,6 +246,16 @@ class GamepadRouter(context: Context, private val handle: Long, private val sett
return ExternalPad(syntheticId, index)
}
/**
* Close the slot (if any) for a physical controller a capture link just claimed. The claim
* detaches the kernel driver, so the system's own removal callback would close it moments
* later anyway doing it at claim time makes the freed wire index deterministic for the
* link's [ExternalPad] instead of racing the link's first report against that callback. Safe
* to over-match (a same-VID/PID sibling that still exists as an InputDevice lazily reopens a
* slot on its next input event). Main thread, like the hot-plug callbacks.
*/
fun releaseDevice(deviceId: Int) = closeSlot(deviceId)
/**
* Flush + drop every slot and unregister the hot-plug listener. Call on session teardown, AFTER
* the feedback poll threads are joined (they read [deviceForPad]).
@@ -0,0 +1,399 @@
package io.unom.punktfunk.kit
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
/**
* Generic USB transport for a client-captured HID controller the device-agnostic half of what
* [Sc2UsbLink] pioneered, now shared with the Sony capture ([DsCapture]). Claims the controller
* interface(s) `force = true` detaches the kernel/OS driver, so a captured pad can't
* double-drive the ordinary InputDevice path runs a multiplexed [UsbRequest] read loop, and
* writes the host/capture's reports back to the device (interrupt-OUT when the interface has one,
* else EP0 `SET_REPORT`).
*
* Everything device-specific is [Config]: which attached device to pick, which of its interfaces
* to claim, and an optional keep-alive (feature reports re-sent on a firmware-watchdog cadence
* the SC2's lizard-mode refresh; a DualSense needs none).
*
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
* (an SC2 on-glass round tripped exactly this a 5 s silence heuristic firing on an idle pad).
* The real signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
*/
class HidUsbLink(
private val context: Context,
private val config: Config,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
) {
/**
* The per-device knowledge this transport is parameterized by. [ifaceFilter] narrows WHICH
* HID/vendor-class interfaces get claimed (the class check itself is built in) e.g. the SC2
* Puck's controller slots, or the DualSense's single HID interface among its audio siblings.
* [keepAliveFeatures] are full feature reports (id byte first) re-sent to the streaming
* interface every [keepAliveMs] AND once at claim time; empty = no keep-alive.
*/
class Config(
val tag: String,
val threadName: String,
val deviceMatch: (UsbDevice) -> Boolean,
val ifaceFilter: (UsbDevice, UsbInterface) -> Boolean = { _, _ -> true },
val keepAliveFeatures: List<ByteArray> = emptyList(),
val keepAliveMs: Long = 0,
)
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
/** One claimed interface: its endpoints + the read state the reader thread owns. */
private class Claim(
val iface: UsbInterface,
val epIn: UsbEndpoint,
val epOut: UsbEndpoint?,
) {
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
var inReq: UsbRequest? = null
var outReq: UsbRequest? = null
var outBusy = false
var reports = 0L
}
private var connection: UsbDeviceConnection? = null
private var device: UsbDevice? = null
private var claims: List<Claim> = emptyList()
/** The claim whose IN endpoint last produced data where output/feature writes go.
* Written by the reader thread, read by the feedback thread (feature control transfers). */
@Volatile private var activeClaim: Claim? = null
/** Pending OUT reports, submitted by the reader thread only one thread may drive a
* connection's [UsbRequest]s ([UsbDeviceConnection.requestWait] returns ANY completed
* request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
/** First attached matching device, or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull(config.deviceMatch)
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
*/
fun start(dev: UsbDevice): Boolean {
if (!usb.hasPermission(dev)) {
Log.e(config.tag, "no USB permission for ${dev.deviceName}")
return false
}
val conn = usb.openDevice(dev) ?: run {
Log.e(config.tag, "openDevice failed for ${dev.deviceName}")
return false
}
val claimed = claimControllerInterfaces(dev, conn)
if (claimed.isEmpty()) {
Log.e(config.tag, "no claimable interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
conn.close()
return false
}
connection = conn
device = dev
claims = claimed
running = true
Log.i(
config.tag,
"USB link up: PID=0x%04x ifaces=%s".format(
dev.productId,
claimed.joinToString {
"%d(in=0x%02x out=%s)".format(
it.iface.id, it.epIn.address,
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
)
},
),
)
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(config.tag, "USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
}
}
}
detachReceiver = receiver
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
if (config.keepAliveFeatures.isNotEmpty()) {
claimed.forEach { sendKeepAlive(conn, it.iface.id) }
}
reader = Thread({ readLoop(conn, claimed) }, config.threadName).apply {
isDaemon = true
start()
}
return true
}
/**
* Claim every candidate controller interface: HID (or vendor-class) interfaces that pass the
* config's [Config.ifaceFilter], with an INT/BULK IN endpoint (OUT optional the fallback is
* EP0 `SET_REPORT`). `force = true` detaches the kernel/OS driver, so the pad also vanishes
* from Android's own input stack while captured.
*/
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
val out = mutableListOf<Claim>()
for (i in 0 until dev.interfaceCount) {
val iface = dev.getInterface(i)
if (!config.ifaceFilter(dev, iface)) continue
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
iface.interfaceClass == 0xFF
if (!hidOrVendor) continue
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (e in 0 until iface.endpointCount) {
val ep = iface.getEndpoint(e)
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
if (!usable) continue
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
}
if (inEp == null) continue
if (conn.claimInterface(iface, true)) {
out.add(Claim(iface, inEp, outEp))
} else {
Log.w(config.tag, "could not claim iface ${iface.id}")
}
}
return out
}
/**
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
*/
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
val live = claims.filter { c ->
val req = UsbRequest()
if (!req.initialize(conn, c.epIn)) {
Log.w(config.tag, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
return@filter false
}
req.clientData = c
c.inReq = req
c.epOut?.let { ep ->
val o = UsbRequest()
if (o.initialize(conn, ep)) {
o.clientData = c
c.outReq = o
} else {
Log.w(config.tag, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
}
}
c.inBuf.clear()
req.queue(c.inBuf)
}
if (live.isEmpty()) {
Log.e(config.tag, "no IN request could be queued")
finishReader(claims)
return
}
val scratch = ByteArray(64)
var lastKeepAlive = android.os.SystemClock.elapsedRealtime()
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
try {
while (running) {
val now = android.os.SystemClock.elapsedRealtime()
if (config.keepAliveFeatures.isNotEmpty() && config.keepAliveMs > 0 &&
now - lastKeepAlive >= config.keepAliveMs
) {
// Refresh the firmware settings on the streaming interface (else every live
// one, before a streaming interface is known) — replaying also repairs state
// some other consumer changed after capture started.
val target = activeClaim
if (target != null) sendKeepAlive(conn, target.iface.id)
else live.forEach { sendKeepAlive(conn, it.iface.id) }
lastKeepAlive = now
}
// Submit the next pending OUT report on the active (else first) interface.
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
if (outTarget != null) {
outQueue.poll()?.let { data ->
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
}
}
val done = try {
conn.requestWait(READ_TIMEOUT_MS)
} catch (_: TimeoutException) {
// A quiet controller is NOT an unplug — keep listening indefinitely; the
// detach broadcast is the real signal.
errorsSince = 0L
continue
}
if (done == null) {
// Hard error. On a real unplug these storm continuously (the detach
// broadcast usually beats us to it); tolerate transient ones.
if (errorsSince == 0L) errorsSince = now
if (now - errorsSince >= ERROR_UNPLUG_MS) {
Log.i(config.tag, "USB request errors persisting ${now - errorsSince} ms — treating as unplug")
break
}
continue
}
errorsSince = 0L
val claim = done.clientData as? Claim ?: continue
if (done === claim.inReq) {
val n = claim.inBuf.position()
if (n > 0) {
claim.inBuf.flip()
claim.inBuf.get(scratch, 0, n)
if (claim.reports++ == 0L) {
Log.i(
config.tag,
"first report on iface %d: id=0x%02x len=%d".format(
claim.iface.id, scratch[0].toInt() and 0xFF, n,
),
)
}
activeClaim = claim
onReport(scratch, n)
}
claim.inBuf.clear()
if (!claim.inReq!!.queue(claim.inBuf)) {
Log.i(config.tag, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
break
}
} else if (done === claim.outReq) {
claim.outBusy = false
}
}
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
}
private fun finishReader(claims: List<Claim>) {
for (c in claims) {
runCatching { c.inReq?.cancel(); c.inReq?.close() }
runCatching { c.outReq?.cancel(); c.outReq?.close() }
c.inReq = null
c.outReq = null
}
}
/**
* Write one raw report to the device: kind 0 = output report (the active interface's
* interrupt-OUT, else a `SET_REPORT(Output)` control transfer), kind 1 = feature report
* (`SET_REPORT(Feature)`). [data] is the full report, id byte first, hidapi framing.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the sender re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
}
}
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
}
/**
* Write one output report EP0-direct (`SET_REPORT(Output)`), bypassing the interrupt-OUT
* queue for a teardown write that must land while the reader thread is stopping and the
* queue would never drain (e.g. a rumble stop before the interfaces release). Safe from any
* thread: EP0 control transfers are independent of the reader's `requestWait`.
*/
fun writeControl(data: ByteArray) {
if (data.isNotEmpty()) setReport(REPORT_TYPE_OUTPUT, data)
}
private fun sendKeepAlive(conn: UsbDeviceConnection, ifaceId: Int) {
for (f in config.keepAliveFeatures) sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, f)
}
/**
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
fun stop() {
running = false
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
claims = emptyList()
runCatching { connection?.close() }
connection = null
device = null
}
private companion object {
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
const val ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
}
@@ -47,6 +47,14 @@ object NativeBridge {
compositorPref: Int,
gamepadPref: Int,
hdrEnabled: Boolean,
/** Every decoder this device would use tolerates multi-slice AUs
* ([VideoDecoders.multiSliceTolerant]) advertises `VIDEO_CAP_MULTI_SLICE`; false keeps
* the host at single-slice frames (the safe pre-0.17 wire shape). */
multiSliceOk: Boolean,
/** Every decoder this device would use accepts partial-frame input
* ([VideoDecoders.partialFrameCapable]) opts into slice-progressive delivery (the
* decode loop then feeds slices with `BUFFER_FLAG_PARTIAL_FRAME` as they arrive). */
framePartsOk: Boolean,
audioChannels: Int,
/** `quic::CODEC_*` bitfield of codecs this device decodes ([VideoDecoders.decodableCodecBits]);
* `0` falls back to H.264|HEVC. The host resolves the emitted codec from this its GPU. */
@@ -184,10 +192,12 @@ object NativeBridge {
external fun nativeVideoMime(handle: Long): String
/**
* The negotiated video mode as `[width, height]`, or `null` on a `0` handle. Resolved at the
* handshake, so it is known before the first frame the stream view sizes itself to THIS
* aspect rather than stretching the picture to the panel's. Fixed for the session; read once.
* Cheap; UI-safe.
* The negotiated video mode as `[width, height, refreshHz]`, or `null` on a `0` handle.
* Resolved at the handshake, so it is known before the first frame the stream view sizes
* itself to THIS aspect rather than stretching the picture to the panel's, and pins the
* panel's display mode to the stream refresh. The trailing `refreshHz` was appended later
* (an older native lib returns only `[width, height]` index defensively). Fixed for the
* session; read once. Cheap; UI-safe.
*/
external fun nativeVideoSize(handle: Long): IntArray?
@@ -204,11 +214,13 @@ object NativeBridge {
* 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 what the pre-overhaul client always did); [lowLatencyMode] is the user's
* "Low-latency mode (experimental)" toggle (off, the default, runs the original decode
* pipeline; on, the aggressive per-SoC tuning + async loop); [lowLatencyFeature] is whether
* "Low-latency mode" master toggle (ON by default: async loop + per-SoC tuning; off runs the
* original synchronous pipeline as the per-device escape hatch); [lowLatencyFeature] is whether
* [decoderName] advertised `FEATURE_LowLatency` (HUD label only). [isTv] drives an active HDMI
* mode switch to the stream refresh on TV boxes when the toggle is on (vs. the softer seamless
* hint otherwise). No-op if already started.
* hint otherwise). [presentPriority]/[smoothBuffer] are the timeline presenter's intent
* (0 = lowest latency / 1 = smoothness; buffer 0 = automatic, else 1..3 frames) the Apple
* client's `present_priority`/`smooth_buffer` pair. No-op if already started.
*/
external fun nativeStartVideo(
handle: Long,
@@ -217,6 +229,11 @@ object NativeBridge {
lowLatencyMode: Boolean,
lowLatencyFeature: Boolean,
isTv: Boolean,
presentPriority: Int,
smoothBuffer: Int,
/** The display mode's own refresh rate (0 = unknown) the latch grid the presenter
* subdivides onto when the platform down-rates the app's choreographer stream. */
panelFps: Int,
)
/** Stop + join the decode thread without closing the session. No-op on `0`. */
@@ -231,11 +248,11 @@ object NativeBridge {
/**
* Drain ~1 s of live decode stats for the on-stream HUD, or `null` when no decode thread runs.
* Returns 26 doubles (unified stats spec, `design/stats-unification.md`):
* Returns 30 doubles (unified stats spec, `design/stats-unification.md`):
* `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
* bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
* netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
* e2eDispP50Ms, e2eDispP95Ms]`
* e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]`
* (the flags are 1.0/0.0; indexes 2/3 are the end-to-end capturedecoded headline; 1013
* describe the negotiated video feed bit depth 8/10, CICP primaries/transfer, and the HEVC
* chroma_format_idc 1=4:2:0 / 3=4:4:4; 14/15 are the stage p50s tiling the headline
@@ -407,6 +424,30 @@ object NativeBridge {
*/
external fun nativeSendPadHidReport(handle: Long, pad: Int, buf: java.nio.ByteBuffer, len: Int)
/**
* One touchpad contact from a client-captured controller (the Sony USB capture), forwarded on
* the rich-input plane (`RichInput::Touchpad`). [finger] is the contact slot (0/1); [x]/[y]
* are normalized 0..65535 in SCREEN convention (+y down the wire's fixed meaning); active
* false lifts the finger. Send on change only the host holds per-slot state.
*/
external fun nativeSendPadTouch(handle: Long, pad: Int, finger: Int, active: Boolean, x: Int, y: Int)
/**
* One motion-sensor sample from a client-captured controller (`RichInput::Motion`): gyro
* pitch/yaw/roll + accel, each a raw signed-16 value in the pad's own units the host passes
* them straight into the virtual DualSense report. Called at the pad's report rate.
*/
external fun nativeSendPadMotion(
handle: Long,
pad: Int,
gyroPitch: Int,
gyroYaw: Int,
gyroRoll: Int,
accelX: Int,
accelY: Int,
accelZ: Int,
)
// ---- Host→client gamepad feedback: Rust pulls block ~100ms, Kotlin renders (see GamepadFeedback) ----
/**
@@ -1,28 +1,13 @@
package io.unom.punktfunk.kit
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbConstants
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbEndpoint
import android.hardware.usb.UsbInterface
import android.hardware.usb.UsbManager
import android.hardware.usb.UsbRequest
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.TimeoutException
/**
* USB transport for a Steam Controller 2 wired (`28DE:1302`) or through the wireless Puck
* dongle (`1304`/`1305`). Claims the controller interface(s) detaching the OS input stack, so
* the pad can't double-drive the ordinary InputDevice path runs a multiplexed [UsbRequest]
* read loop, keeps lizard mode off on the firmware watchdog cadence, and replays the host's raw
* writes (Steam's rumble output reports / settings feature reports) back to the device.
* dongle (`1304`/`1305`). The SC2 specialization of the shared [HidUsbLink] transport (which owns
* the claim, read loop, write queue, and unplug handling); this class contributes only what is
* SC2-specific:
*
* **The Puck claims ALL controller interfaces (2..5):** the dongle hosts up to four pads, one
* HID interface each, and there is no way to know which slot a controller bonded to claiming
@@ -30,350 +15,50 @@ import java.util.concurrent.TimeoutException
* on-glass symptom: the pad surfaced as a generic InputDevice Xbox360). Whichever interface
* streams state becomes the write target for rumble/settings.
*
* **Unplug is signalled, never inferred from silence:** a quiet controller is not a missing one
* (round 2's wired disconnect was the 5 s silence heuristic firing on an idle pad). The real
* signals are [UsbManager.ACTION_USB_DEVICE_DETACHED] for this device, or `requestWait`
* returning sustained hard errors (every transfer fails instantly once the fd is dead).
* **Lizard keep-alive:** the firmware watchdog re-enables lizard mode (built-in kb/mouse
* emulation) after a few seconds of silence, so [Sc2Device.DISABLE_LIZARD] +
* [Sc2Device.NORMALIZE_JOYSTICKS] are re-sent on SDL's cadence the generic link's keep-alive.
*/
class Sc2UsbLink(
private val context: Context,
private val onReport: (report: ByteArray, len: Int) -> Unit,
private val onClosed: () -> Unit,
context: Context,
onReport: (report: ByteArray, len: Int) -> Unit,
onClosed: () -> Unit,
) {
private val usb = context.getSystemService(Context.USB_SERVICE) as UsbManager
/** One claimed interface: its endpoints + the read state the reader thread owns. */
private class Claim(
val iface: UsbInterface,
val epIn: UsbEndpoint,
val epOut: UsbEndpoint?,
) {
val inBuf: ByteBuffer = ByteBuffer.allocate(64)
var inReq: UsbRequest? = null
var outReq: UsbRequest? = null
var outBusy = false
var reports = 0L
}
private var connection: UsbDeviceConnection? = null
private var device: UsbDevice? = null
private var claims: List<Claim> = emptyList()
/** The claim whose IN endpoint last produced data where rumble/settings writes go.
* Written by the reader thread, read by the feedback thread (feature control transfers). */
@Volatile private var activeClaim: Claim? = null
/** Pending OUT reports (Steam's forwarded haptics), submitted by the reader thread only
* one thread may drive a connection's [UsbRequest]s ([UsbDeviceConnection.requestWait]
* returns ANY completed request; a second waiter would steal the reader's completions). */
private val outQueue = ConcurrentLinkedQueue<ByteArray>()
private var reader: Thread? = null
private var detachReceiver: BroadcastReceiver? = null
@Volatile private var running = false
private val link = HidUsbLink(
context,
HidUsbLink.Config(
tag = "Sc2UsbLink",
threadName = "pf-sc2-usb",
deviceMatch = {
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
},
// Wired: every HID/vendor interface; dongle: only the controller slots 2..5.
ifaceFilter = { dev, iface ->
dev.productId == Sc2Device.PID_WIRED || iface.id in Sc2Device.DONGLE_IFACES
},
keepAliveFeatures = listOf(Sc2Device.DISABLE_LIZARD, Sc2Device.NORMALIZE_JOYSTICKS),
keepAliveMs = Sc2Device.LIZARD_REFRESH_MS,
),
onReport,
onClosed,
)
/** First attached SC2 (wired or Puck), or null. Does not need USB permission to enumerate. */
fun findDevice(): UsbDevice? = usb.deviceList.values.firstOrNull {
it.vendorId == Sc2Device.VID_VALVE && it.productId in Sc2Device.USB_PIDS
}
fun findDevice(): UsbDevice? = link.findDevice()
/**
* Claim [dev]'s controller interface(s) and start the read loop. The caller has already
* obtained USB permission. Returns false when nothing could be claimed.
*/
fun start(dev: UsbDevice): Boolean {
if (!usb.hasPermission(dev)) {
Log.e(TAG, "no USB permission for ${dev.deviceName}")
return false
}
val conn = usb.openDevice(dev) ?: run {
Log.e(TAG, "openDevice failed for ${dev.deviceName}")
return false
}
val claimed = claimControllerInterfaces(dev, conn)
if (claimed.isEmpty()) {
Log.e(TAG, "no claimable SC2 interface on ${dev.deviceName} (PID=0x%04x)".format(dev.productId))
conn.close()
return false
}
connection = conn
device = dev
claims = claimed
running = true
Log.i(
TAG,
"SC2 USB link up: PID=0x%04x ifaces=%s".format(
dev.productId,
claimed.joinToString {
"%d(in=0x%02x out=%s)".format(
it.iface.id, it.epIn.address,
it.epOut?.let { e -> "0x%02x".format(e.address) } ?: "-",
)
},
),
)
// The REAL unplug signal — silence never is (an idle pad may simply stop streaming).
val receiver = object : BroadcastReceiver() {
override fun onReceive(c: Context?, intent: Intent?) {
if (intent?.action != UsbManager.ACTION_USB_DEVICE_DETACHED) return
val gone: UsbDevice? = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE)
if (gone?.deviceName == dev.deviceName) {
Log.i(TAG, "SC2 USB detached (${dev.deviceName})")
if (running) {
running = false
onClosed()
}
}
}
}
detachReceiver = receiver
val filter = IntentFilter(UsbManager.ACTION_USB_DEVICE_DETACHED)
if (Build.VERSION.SDK_INT >= 33) {
context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
@Suppress("UnspecifiedRegisterReceiverFlag")
context.registerReceiver(receiver, filter)
}
claimed.forEach { configureInputMode(conn, it.iface.id) }
reader = Thread({ readLoop(conn, claimed) }, "pf-sc2-usb").apply {
isDaemon = true
start()
}
return true
}
/**
* Claim every candidate controller interface: the wired pad's single HID interface, or ALL
* of a Puck's controller slots (interfaces 2..5 the controller may be bonded to any of
* them). `force = true` detaches the kernel/OS driver, so the pad also vanishes from
* Android's own input stack while captured.
*/
private fun claimControllerInterfaces(dev: UsbDevice, conn: UsbDeviceConnection): List<Claim> {
val dongle = dev.productId != Sc2Device.PID_WIRED
val out = mutableListOf<Claim>()
for (i in 0 until dev.interfaceCount) {
val iface = dev.getInterface(i)
if (dongle && iface.id !in Sc2Device.DONGLE_IFACES) continue
val hidOrVendor = iface.interfaceClass == UsbConstants.USB_CLASS_HID ||
iface.interfaceClass == 0xFF
if (!hidOrVendor) continue
var inEp: UsbEndpoint? = null
var outEp: UsbEndpoint? = null
for (e in 0 until iface.endpointCount) {
val ep = iface.getEndpoint(e)
val usable = ep.type == UsbConstants.USB_ENDPOINT_XFER_INT ||
ep.type == UsbConstants.USB_ENDPOINT_XFER_BULK
if (!usable) continue
if (ep.direction == UsbConstants.USB_DIR_IN && inEp == null) inEp = ep
if (ep.direction == UsbConstants.USB_DIR_OUT && outEp == null) outEp = ep
}
if (inEp == null) continue
if (conn.claimInterface(iface, true)) {
out.add(Claim(iface, inEp, outEp))
} else {
Log.w(TAG, "could not claim iface ${iface.id}")
}
}
return out
}
/**
* The multiplexed read loop: one IN request queued per claimed interface at all times, OUT
* writes submitted from [outQueue], completions routed via [UsbRequest.getClientData].
*/
private fun readLoop(conn: UsbDeviceConnection, claims: List<Claim>) {
val live = claims.filter { c ->
val req = UsbRequest()
if (!req.initialize(conn, c.epIn)) {
Log.w(TAG, "UsbRequest.initialize(IN, iface ${c.iface.id}) failed")
return@filter false
}
req.clientData = c
c.inReq = req
c.epOut?.let { ep ->
val o = UsbRequest()
if (o.initialize(conn, ep)) {
o.clientData = c
c.outReq = o
} else {
Log.w(TAG, "UsbRequest.initialize(OUT, iface ${c.iface.id}) failed — output reports via EP0")
}
}
c.inBuf.clear()
req.queue(c.inBuf)
}
if (live.isEmpty()) {
Log.e(TAG, "no IN request could be queued")
finishReader(claims)
return
}
val scratch = ByteArray(64)
var lastLizard = android.os.SystemClock.elapsedRealtime()
var errorsSince = 0L // elapsedRealtime of the first hard error in the current streak
try {
while (running) {
val now = android.os.SystemClock.elapsedRealtime()
if (now - lastLizard >= Sc2Device.LIZARD_REFRESH_MS) {
// Refresh both required firmware modes. The raw-joystick setting is normally
// persistent, but replaying it also repairs a host/driver that enabled ADC
// coordinates after capture started.
val target = activeClaim
if (target != null) configureInputMode(conn, target.iface.id)
else live.forEach { configureInputMode(conn, it.iface.id) }
lastLizard = now
}
// Submit the next pending OUT report on the active (else first) interface.
val outTarget = (activeClaim ?: live.first()).takeIf { it.outReq != null && !it.outBusy }
if (outTarget != null) {
outQueue.poll()?.let { data ->
if (outTarget.outReq!!.queue(ByteBuffer.wrap(data))) outTarget.outBusy = true
}
}
val done = try {
conn.requestWait(READ_TIMEOUT_MS)
} catch (_: TimeoutException) {
// A quiet controller is NOT an unplug — keep listening indefinitely; the
// detach broadcast is the real signal.
errorsSince = 0L
continue
}
if (done == null) {
// Hard error. On a real unplug these storm continuously (the detach
// broadcast usually beats us to it); tolerate transient ones.
if (errorsSince == 0L) errorsSince = now
if (now - errorsSince >= ERROR_UNPLUG_MS) {
Log.i(TAG, "SC2 USB request errors persisting ${now - errorsSince} ms — treating as unplug")
break
}
continue
}
errorsSince = 0L
val claim = done.clientData as? Claim ?: continue
if (done === claim.inReq) {
val n = claim.inBuf.position()
if (n > 0) {
claim.inBuf.flip()
claim.inBuf.get(scratch, 0, n)
if (claim.reports++ == 0L) {
Log.i(
TAG,
"SC2 first report on iface %d: id=0x%02x len=%d".format(
claim.iface.id, scratch[0].toInt() and 0xFF, n,
),
)
}
activeClaim = claim
onReport(scratch, n)
}
claim.inBuf.clear()
if (!claim.inReq!!.queue(claim.inBuf)) {
Log.i(TAG, "re-queue(IN, iface ${claim.iface.id}) failed — treating as unplug")
break
}
} else if (done === claim.outReq) {
claim.outBusy = false
}
}
} finally {
finishReader(claims)
}
if (running) {
running = false
onClosed()
}
}
private fun finishReader(claims: List<Claim>) {
for (c in claims) {
runCatching { c.inReq?.cancel(); c.inReq?.close() }
runCatching { c.outReq?.cancel(); c.outReq?.close() }
c.inReq = null
c.outReq = null
}
}
fun start(dev: UsbDevice): Boolean = link.start(dev)
/**
* Replay one raw report from the host on the device: kind 0 = output report (Steam's `0x80`
* rumble & friends the active interface's interrupt-OUT, else a `SET_REPORT(Output)`
* control transfer), kind 1 = feature report (`SET_REPORT(Feature)`). [data] is the full
* report, id byte first, exactly as hidapi framed it host-side.
* rumble & friends), kind 1 = feature report. [data] is the full report, id byte first,
* exactly as hidapi framed it host-side.
*/
fun writeRaw(kind: Int, data: ByteArray) {
if (data.isEmpty()) return
when (kind) {
0 -> {
if ((activeClaim ?: claims.firstOrNull())?.outReq != null) {
// Interrupt-OUT rides UsbRequests submitted by the reader thread. Bounded,
// newest-wins: these are level-styled commands the host re-sends anyway.
while (outQueue.size >= 32) outQueue.poll()
outQueue.offer(data)
} else {
setReport(REPORT_TYPE_OUTPUT, data)
}
}
1 -> setReport(REPORT_TYPE_FEATURE, data)
}
}
fun writeRaw(kind: Int, data: ByteArray) = link.writeRaw(kind, data)
private fun setReport(type: Int, data: ByteArray) {
val conn = connection ?: return
val ifId = (activeClaim ?: claims.firstOrNull())?.iface?.id ?: return
sendReport(conn, ifId, type, data)
}
private fun configureInputMode(conn: UsbDeviceConnection, ifaceId: Int) {
sendFeature(conn, ifaceId, Sc2Device.DISABLE_LIZARD)
sendFeature(conn, ifaceId, Sc2Device.NORMALIZE_JOYSTICKS)
}
private fun sendFeature(conn: UsbDeviceConnection, ifaceId: Int, data: ByteArray) {
sendReport(conn, ifaceId, REPORT_TYPE_FEATURE, data)
}
/**
* HID `SET_REPORT` control transfer with hidapi's report-id framing: a non-zero leading byte
* is the report id (sent in wValue AND kept in the payload); a zero leading byte means
* "unnumbered" (id 0 in wValue, id byte stripped from the payload). EP0 is independent of
* the interrupt endpoints, so this is safe alongside the reader thread's requestWait.
*/
private fun sendReport(conn: UsbDeviceConnection, ifaceId: Int, type: Int, data: ByteArray) {
val id = data[0].toInt() and 0xFF
val payload = if (id == 0) data.copyOfRange(1, data.size) else data
conn.controlTransfer(
0x21, // host→device, class, interface
0x09, // SET_REPORT
(type shl 8) or id,
ifaceId,
payload,
payload.size,
WRITE_TIMEOUT_MS,
)
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire [onClosed]. */
fun stop() {
running = false
detachReceiver?.let { runCatching { context.unregisterReceiver(it) } }
detachReceiver = null
runCatching { reader?.join(1000) }
reader = null
outQueue.clear()
activeClaim = null
for (c in claims) runCatching { connection?.releaseInterface(c.iface) }
claims = emptyList()
runCatching { connection?.close() }
connection = null
device = null
}
private companion object {
const val TAG = "Sc2UsbLink"
const val READ_TIMEOUT_MS = 100L
const val WRITE_TIMEOUT_MS = 250
/** Hard `requestWait` ERRORS (not timeouts) persisting this long = the fd is dead. */
const val ERROR_UNPLUG_MS = 2000L
const val REPORT_TYPE_OUTPUT = 0x02
const val REPORT_TYPE_FEATURE = 0x03
}
/** Stop the read loop and release the interfaces. Idempotent; does not fire the closed callback. */
fun stop() = link.stop()
}
@@ -53,6 +53,53 @@ object VideoDecoders {
fun decodableCodecBits(): Int =
1 or 2 or (if (pickDecoder("video/av01") != null) 4 else 0)
/**
* Whether EVERY decoder this device would use tolerates multi-slice access units the
* `VIDEO_CAP_MULTI_SLICE` advertisement (decoder truth, per the 0.17.0 field regression:
* Amlogic HEVC decoders wedge the whole DEVICE on multi-slice frames Chromecast with
* Google TV, onn 4K which is why Moonlight requests single-slice for every hardware
* decoder). We advertise per-family instead: tolerant unless the pick for ANY advertised
* codec is an Amlogic decoder or can't be inspected (a null pick = the platform default
* uninspectable, so conservatively single-slice). Probed once at connect time; the host
* defaults to >1 slice only toward clients that set the bit.
*/
fun multiSliceTolerant(): Boolean {
val mimes = buildList {
add("video/avc")
add("video/hevc")
if (decodableCodecBits() and 4 != 0) add("video/av01")
}
return mimes.all { mime ->
val name = pickDecoder(mime)?.name?.lowercase() ?: return@all false
!name.startsWith("omx.amlogic") && !name.startsWith("c2.amlogic")
}
}
/**
* True when EVERY decoder this device would stream with supports partial-frame input
* (MediaCodec `FEATURE_PartialFrame`): the client may then opt into slice-progressive
* delivery and feed slices with `BUFFER_FLAG_PARTIAL_FRAME` ahead of the AU's tail.
* Codec selection happens after connect, so all advertised mimes must qualify the same
* conservative shape as [multiSliceTolerant] (an unprobeable pick disqualifies).
*/
fun partialFrameCapable(): Boolean {
val mimes = buildList {
add("video/avc")
add("video/hevc")
if (decodableCodecBits() and 4 != 0) add("video/av01")
}
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
.getOrNull() ?: return false
return mimes.all { mime ->
val pick = pickDecoder(mime)?.name ?: return@all false
val info = infos.firstOrNull { it.name == pick } ?: return@all false
runCatching {
info.getCapabilitiesForType(mime)
.isFeatureSupported(CodecCapabilities.FEATURE_PartialFrame)
}.getOrDefault(false)
}
}
fun pickDecoder(mime: String): DecoderChoice? {
if (mime.isEmpty()) return null
val infos = runCatching { MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos }
@@ -0,0 +1,224 @@
package io.unom.punktfunk.kit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pure JVM tests of the Sony USB report codec ([DsDevice]) the byte-exact inverse of the
* host's `dualsense_proto.rs` / `dualshock4_proto.rs` serializers (offsets cross-checked against
* those files' own tests). No Android runtime types ([Gamepad]'s BTN_* are compile-time ints).
* Run: `./gradlew :kit:testDebugUnitTest`.
*/
class DsDeviceTest {
private fun ds5Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
ByteArray(64).also {
it[0] = 0x01
// Sticks centred, hat neutral (8).
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
it[8] = 0x08
// Touch points inactive (bit7 set).
it[33] = 0x80.toByte(); it[37] = 0x80.toByte()
mutate(it)
}
private fun ds4Report(mutate: (ByteArray) -> Unit = {}): ByteArray =
ByteArray(64).also {
it[0] = 0x01
it[1] = 0x80.toByte(); it[2] = 0x80.toByte(); it[3] = 0x80.toByte(); it[4] = 0x80.toByte()
it[5] = 0x08
it[35] = 0x80.toByte(); it[39] = 0x80.toByte()
mutate(it)
}
// ---- input parse ----
@Test
fun ds5ButtonsMapPositionally() {
val s = DsDevice.State()
// cross+triangle, hat NE, L1+create+L3, PS+touchpad+mute.
val r = ds5Report {
it[8] = (0x20 or 0x80 or 0x01).toByte() // cross | triangle | hat=1 (NE)
it[9] = (0x01 or 0x10 or 0x40).toByte() // L1 | create | L3
it[10] = (0x01 or 0x02 or 0x04).toByte() // PS | touchpad | mute
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
val expected = Gamepad.BTN_A or Gamepad.BTN_Y or
Gamepad.BTN_DPAD_UP or Gamepad.BTN_DPAD_RIGHT or
Gamepad.BTN_LB or Gamepad.BTN_BACK or Gamepad.BTN_LS_CLICK or
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD or Gamepad.BTN_MISC1
assertEquals(expected, s.buttons)
}
@Test
fun ds5SticksInvertYAndCoverTheFullRange() {
val s = DsDevice.State()
// Device +y down; wire +y up. Left stick fully up-left, right stick fully down-right.
val r = ds5Report {
it[1] = 0x00; it[2] = 0x00 // lx min, ly min (up)
it[3] = 0xFF.toByte(); it[4] = 0xFF.toByte() // rx max, ry max (down)
it[5] = 0x40; it[6] = 0xFF.toByte()
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
assertEquals(-32768, s.lsX)
assertEquals(32767, s.lsY) // device up → wire +32767
assertEquals(32767, s.rsX)
assertEquals(-32768, s.rsY) // device down → wire 32768
assertEquals(0x40, s.lt)
assertEquals(0xFF, s.rt)
// Centre stays (near) centre: 0x80 → 128 wire units of bias, the u8 grid's own offset.
val c = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 64, c))
assertEquals(128, c.lsX)
assertEquals(-129, c.lsY)
}
@Test
fun ds5MotionAndTouchUnpack() {
val s = DsDevice.State()
val r = ds5Report {
// gyro pitch = 0x0102, accel z = -2 (LE i16s at 16.. / 22..).
it[16] = 0x02; it[17] = 0x01
it[26] = 0xFE.toByte(); it[27] = 0xFF.toByte()
// Touch 0 active, id 5, x=1919 (0x77F), y=1079 (0x437):
// b0=0x05, b1=0x7F, b2=(x>>8)|((y&0xF)<<4)=0x77, y>>4=0x43.
it[33] = 0x05
it[34] = 0x7F
it[35] = (0x07 or (0x07 shl 4)).toByte()
it[36] = 0x43
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, s))
assertEquals(0x0102, s.gyro[0])
assertEquals(-2, s.accel[2])
assertTrue(s.touchActive[0])
assertEquals(1919, s.touchX[0])
assertEquals(1079, s.touchY[0])
assertFalse(s.touchActive[1])
}
@Test
fun edgePaddlesParseOnlyOnTheEdge() {
val r = ds5Report { it[10] = 0xF0.toByte() } // all four FN/BACK bits
val edge = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE_EDGE, r, 64, edge))
// Host inverse (`edge_paddle_bits`): PADDLE1/2 = right/left BACK, PADDLE3/4 = right/left Fn.
assertEquals(
Gamepad.BTN_PADDLE1 or Gamepad.BTN_PADDLE2 or Gamepad.BTN_PADDLE3 or Gamepad.BTN_PADDLE4,
edge.buttons,
)
val plain = DsDevice.State()
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSENSE, r, 64, plain))
assertEquals(0, plain.buttons) // a non-Edge never reports phantom paddles
}
@Test
fun ds4LayoutDiffersWhereItShould() {
val s = DsDevice.State()
val r = ds4Report {
it[5] = (0x10 or 0x04).toByte() // square | hat=4 (down)
it[6] = (0x10 or 0x20).toByte() // share | options
it[7] = 0x03 // PS | touchpad click
it[8] = 0x11 // L2 analog
it[9] = 0x99.toByte() // R2 analog
// gyro yaw at 15.. (second i16 of 13..19).
it[15] = 0x34; it[16] = 0x12
// Touch 0 active id 3 at x=100 (0x064), y=941 (0x3AD): b1=0x64, b2=0xD0, b3=0x3A.
it[35] = 0x03
it[36] = 0x64
it[37] = 0xD0.toByte()
it[38] = 0x3A
}
assertTrue(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, r, 64, s))
assertEquals(
Gamepad.BTN_X or Gamepad.BTN_DPAD_DOWN or Gamepad.BTN_BACK or Gamepad.BTN_START or
Gamepad.BTN_GUIDE or Gamepad.BTN_TOUCHPAD,
s.buttons,
)
assertEquals(0x11, s.lt)
assertEquals(0x99, s.rt)
assertEquals(0x1234, s.gyro[1])
assertTrue(s.touchActive[0])
assertEquals(100, s.touchX[0])
assertEquals(941, s.touchY[0])
}
@Test
fun rejectsForeignAndShortReports() {
val s = DsDevice.State()
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report { it[0] = 0x31 }, 64, s))
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSENSE, ds5Report(), 8, s))
assertFalse(DsDevice.parseState(DsDevice.Model.DUALSHOCK4, ds4Report(), 8, s))
}
// ---- output builders (offsets = the host parser's: `parse_ds_output` / `parse_ds4_output`) ----
@Test
fun ds5RumbleReportFlagsAndMotors() {
val r = DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, low = 0xFF00, high = 0x1200)
assertEquals(48, r.size)
assertEquals(0x02, r[0].toInt())
assertEquals(0x03, r[1].toInt()) // compat vibration | haptics select
assertEquals(0x04, r[39].toInt()) // VIBRATION2 (fw ≥ 2.24)
assertEquals(0x12, r[3].toInt() and 0xFF) // high = right/small at [3]
assertEquals(0xFF, r[4].toInt() and 0xFF) // low = left/big at [4]
// A nonzero amplitude never collapses to motor 0.
assertEquals(1, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE, 0x00FF, 0)[4].toInt())
// The Edge's output report is the 64-byte variant.
assertEquals(64, DsDevice.ds5RumbleReport(DsDevice.Model.DUALSENSE_EDGE, 0, 0).size)
}
@Test
fun ds5TriggerReportPlacesTheBlockPerSide() {
val effect = ByteArray(11) { (it + 1).toByte() }
val r2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 1, effect = effect)
assertEquals(0x04, r2[1].toInt()) // R2 valid flag
assertEquals(1, r2[11].toInt()) // block at [11..22)
assertEquals(11, r2[21].toInt())
assertEquals(0, r2[22].toInt())
val l2 = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, which = 0, effect = effect)
assertEquals(0x08, l2[1].toInt()) // L2 valid flag
assertEquals(1, l2[22].toInt()) // block at [22..33)
assertEquals(11, l2[32].toInt())
// Oversized wire effects clamp to the 11-byte hardware block.
val big = DsDevice.ds5TriggerReport(DsDevice.Model.DUALSENSE, 1, ByteArray(20) { 0x7F })
assertEquals(0, big[22].toInt())
}
@Test
fun ds5LightbarPlayerLedsAndInit() {
val led = DsDevice.ds5LightbarReport(DsDevice.Model.DUALSENSE, 1, 2, 3)
assertEquals(0x04, led[2].toInt()) // lightbar valid flag
assertEquals(1, led[45].toInt()); assertEquals(2, led[46].toInt()); assertEquals(3, led[47].toInt())
val pl = DsDevice.ds5PlayerLedsReport(DsDevice.Model.DUALSENSE, 0xFF)
assertEquals(0x10, pl[2].toInt()) // player-LED valid flag
assertEquals(0x1F, pl[44].toInt()) // masked to the 5 LEDs
val init = DsDevice.ds5InitReport(DsDevice.Model.DUALSENSE)
assertEquals(0x02, init[39].toInt()) // lightbar-setup enable
assertEquals(0x02, init[42].toInt()) // LIGHT_OUT — releases the firmware animation
}
@Test
fun ds4ReportIsAFullStateWrite() {
val r = DsDevice.ds4Report(low = 0xAB00, high = 0x0100, r = 9, g = 8, b = 7)
assertEquals(32, r.size)
assertEquals(0x05, r[0].toInt())
assertEquals(0x03, r[1].toInt()) // motors | LED, both — composed state
assertEquals(0x01, r[4].toInt()) // high = weak/right at [4]
assertEquals(0xAB, r[5].toInt() and 0xFF) // low = strong/left at [5]
assertEquals(9, r[6].toInt()); assertEquals(8, r[7].toInt()); assertEquals(7, r[8].toInt())
assertEquals(0, r[9].toInt()) // blink untouched
}
@Test
fun modelResolution() {
assertEquals(DsDevice.Model.DUALSENSE, DsDevice.modelFor(0x0CE6))
assertEquals(DsDevice.Model.DUALSENSE_EDGE, DsDevice.modelFor(0x0DF2))
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x05C4))
assertEquals(DsDevice.Model.DUALSHOCK4, DsDevice.modelFor(0x09CC))
assertEquals(null, DsDevice.modelFor(0x1234))
assertEquals(Gamepad.PREF_DUALSENSE, DsDevice.Model.DUALSENSE.pref)
assertEquals(Gamepad.PREF_DUALSENSEEDGE, DsDevice.Model.DUALSENSE_EDGE.pref)
assertEquals(Gamepad.PREF_DUALSHOCK4, DsDevice.Model.DUALSHOCK4.pref)
}
}
+316 -36
View File
@@ -17,10 +17,12 @@ use super::display::{
apply_hdr_dataspace, install_render_callback, release_render_callback, DisplayTracker,
};
use super::latency::{note_decoded_pts, now_realtime_ns, take_flags};
use super::presenter::{presenter_disabled_by_sysprop, PresentMeter, PresentPriority, Presenter};
use super::setup::{
android_hdr_static_info, boost_hot_threads, boost_thread_priority, codec_mime,
configure_low_latency, create_codec, try_set_frame_rate,
};
use super::vsync::{now_monotonic_ns, VsyncClock};
use super::{
DecodeOptions, FRAME_PARK_CAP, IN_FLIGHT_CAP, NO_OUTPUT_PATIENCE, NO_VIDEO_PATIENCE,
NO_VIDEO_RETRY, PENDING_SPLIT_CAP,
@@ -55,6 +57,8 @@ enum DecodeEvent {
},
/// The output format changed — re-check the stream's colour signalling (HDR DataSpace).
FormatChanged,
/// A panel vsync (from the [`VsyncClock`] thread) — the presenter's retry/pacing tick.
Vsync,
/// The codec reported an error; `fatal` when neither recoverable nor transient.
Error { fatal: bool },
}
@@ -78,6 +82,9 @@ pub(super) fn run_async(
ll_feature,
low_latency_mode,
is_tv,
present_priority,
smooth_buffer,
panel_hz,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -196,9 +203,33 @@ pub(super) fn run_async(
// parked in the tracker at release; the OnFrameRendered callback pairs it with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
let meter = Arc::new(PresentMeter::new());
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone(), meter.clone());
let render_cb = install_render_callback(&codec, &tracker);
// The timeline presenter (see `presenter.rs`): newest-wins / smoothing store, one-in-flight
// glass budget, timeline-timed release. `debug.punktfunk.presenter = arrival` selects the
// legacy release-immediately path for a rebuild-free on-device A/B.
let mut presenter = if presenter_disabled_by_sysprop() {
log::info!("decode: presenter = arrival (sysprop) — legacy immediate release");
None
} else {
let priority = PresentPriority::resolve(present_priority, smooth_buffer);
log::info!(
"decode: presenter = timeline ({})",
match priority {
PresentPriority::Latency => "lowest latency".to_string(),
PresentPriority::Smooth { buffer } => format!("smoothness, buffer {buffer}"),
}
);
Some(Presenter::new(priority))
};
stats.set_presenter_active(presenter.is_some());
// The vsync clock, started LAZILY on the first decoded frame (see `vsync.rs`); its ticks ride
// the same event channel. The Sender parks here until that moment.
let mut vsync: Option<VsyncClock> = None;
let mut vsync_tx = presenter.is_some().then(|| ev_tx.clone());
// 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 = {
@@ -238,6 +269,8 @@ pub(super) fn run_async(
let mut free_inputs: VecDeque<usize> = VecDeque::new();
let mut pending_aus: VecDeque<Frame> = VecDeque::new();
// Phase-lock v3: per-AU arrival stamps for the circular arrival-lead report (drained 1 Hz).
let mut arrival_stamps: Vec<i128> = Vec::new();
let mut ready: Vec<OutputReady> = Vec::new();
let mut applied_ds: Option<DataSpace> = None;
let mut fed: u64 = 0;
@@ -245,6 +278,8 @@ pub(super) fn run_async(
let mut discarded: u64 = 0;
// AUs larger than the codec input buffer, dropped whole (see `feed`/`feed_ready`).
let mut oversized_dropped: u64 = 0;
// Slice-progressive continuity ledger (see `PartFeed`).
let mut part_open: Option<PartFeed> = None;
// Freeze-until-reanchor gate (see the sync loop for the rationale). Armed on a frame-index gap
// (the feeder's Au verdict), a parked-AU overflow drop, a dropped-count climb, or a recoverable
// codec error; `recovery_flags` carries each AU's user_flags from `dispatch_event` (feed) to
@@ -277,6 +312,7 @@ pub(super) fn run_async(
};
let work_t0 = Instant::now();
let mut fmt_dirty = false;
let mut vsync_tick = false;
let mut aus_dropped: u64 = 0;
if let Some(ev) = ev0 {
aus_dropped += u64::from(dispatch_event(
@@ -285,9 +321,11 @@ pub(super) fn run_async(
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
}
// Coalesce every other event already queued into this one work pass — correct newest-only
@@ -299,11 +337,18 @@ pub(super) fn run_async(
&mut free_inputs,
&mut ready,
&mut fmt_dirty,
&mut vsync_tick,
&mut fatal,
&mut gate,
&mut recovery_flags,
&mut arrival_stamps,
));
}
if vsync_tick {
if let Some(p) = presenter.as_mut() {
p.on_vsync();
}
}
stats.note_skipped(aus_dropped); // parked-AU overflow drops are client-side skips too
if fmt_dirty {
apply_hdr_dataspace(&codec, &window, &mut applied_ds);
@@ -315,8 +360,11 @@ pub(super) fn run_async(
&mut free_inputs,
&mut fed,
&mut oversized_dropped,
&mut part_open,
&mut gate,
);
let had_output = !ready.is_empty();
let rendered_before = rendered;
present_ready(
&codec,
&client,
@@ -326,14 +374,88 @@ pub(super) fn run_async(
&in_flight,
clock_offset.load(Ordering::Relaxed),
&tracker,
&mut presenter,
&mut rendered,
&mut discarded,
&mut gate,
&mut recovery_flags,
);
// The presenter's decision point runs EVERY pass — frame arrivals, vsync ticks and the
// 5 ms housekeeping wake all land here, which is what reopens the glass budget on time
// even when the choreographer clock is absent.
if let Some(p) = presenter.as_mut() {
let clock = vsync.as_ref().map(|v| v.shared().as_ref());
if p.pump(&codec, clock, &tracker, &stats, now_monotonic_ns()) {
rendered += 1;
}
// The 1 Hz window flush doubles as the phase-lock report tick. v3 sensor: the
// CIRCULAR mean + coherence of the ARRIVAL lead — each AU's reassembly stamp
// against the panel's latch grid — because arrival is the phase the host actually
// controls; the v2 latch statistic measured downstream of the decoder pipeline,
// which absorbed the actuation (on-glass 2026-07-31). Timestamps convert
// monotonic→realtime→host — the skew offset lives client-side.
if let (Some(_), Some(c)) = (p.flush_log(&meter, clock), clock) {
let period = c.panel_period_ns().max(c.period_ns());
if period > 0 {
if let Some(t) = c.next_target(now_monotonic_ns(), 0) {
let mono_now = now_monotonic_ns();
let real_now = now_realtime_ns();
let leads_us: Vec<u64> = arrival_stamps
.iter()
.map(|&r_ns| {
let arrival_mono = mono_now as i128 - (real_now - r_ns);
((t.expected_present_ns as i128 - arrival_mono)
.rem_euclid(period as i128)
/ 1000) as u64
})
.collect();
arrival_stamps.clear();
if let Some((lead_mean_ns, coherence)) =
punktfunk_core::phase::circular_latch(&leads_us, period)
{
log::info!(
target: "pf.phase",
"arrival lead circ={:.2}ms coh={}",
lead_mean_ns as f64 / 1e6,
coherence
);
let latch_real_ns =
real_now + (t.expected_present_ns - mono_now) as i128;
let latch_host_ns = (latch_real_ns
+ clock_offset.load(Ordering::Relaxed) as i128)
.max(0) as u64;
client.report_phase(
latch_host_ns,
period.clamp(0, u32::MAX as i64) as u32,
1_000_000, // skew residual — conservative 1 ms
lead_mean_ns.min(u32::MAX as u64) as u32,
coherence,
);
}
}
}
}
}
let presented_now = rendered > rendered_before;
// Start the vsync clock LAZILY on the first decoded output (eager, it ticks the panel
// rate into a session that has no frame yet — the Apple deadline presenter's bootstrap
// lesson). A `None` from start (no choreographer surface) simply leaves ASAP targets.
if had_output && vsync.is_none() {
if let Some(tx) = vsync_tx.take() {
vsync = VsyncClock::start(
panel_hz,
Box::new(move || {
let _ = tx.send(DecodeEvent::Vsync);
}),
);
if vsync.is_none() {
log::info!("decode: no choreographer clock — presenter uses ASAP targets");
}
}
}
work_accum_ns += work_t0.elapsed().as_nanos() as i64;
if had_output {
if presented_now {
if !hint_tried {
hint_tried = true;
let tids = client.hot_thread_ids();
@@ -420,6 +542,10 @@ pub(super) fn run_async(
}
}
if let Some(p) = presenter.as_mut() {
p.release_all(&codec); // hand every held output buffer back before the codec stops
}
drop(vsync); // stop + join the choreographer thread; its channel sends are harmless after
let _ = codec.stop();
shutdown.store(true, Ordering::SeqCst); // ensure the feeder wakes and exits, then join it
if let Some(j) = feeder {
@@ -448,6 +574,9 @@ fn feeder_loop(
) {
// 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();
// Last logged phase-lock ACK (the host's applied capture hold, from the 0xCF tail) — logged
// on change so `adb logcat -s pf.phase` shows the closed loop working (or not) at a glance.
let mut last_phase_ack: Option<i32> = None;
while !shutdown.load(Ordering::Relaxed) {
match client.next_frame(Duration::from_millis(5)) {
Ok(frame) => {
@@ -455,11 +584,15 @@ fn feeder_loop(
// invalidation request so an RFI-capable host recovers with a cheap clean P-frame
// instead of a full IDR (the frames_dropped keyframe path is the backstop). The gap
// verdict rides the Au event so the decode loop arms its freeze gate on the same signal.
let gap = client.note_frame_index(frame.frame_index);
// Slice-progressive parts repeat their AU's index — note it once, on the
// AU's first piece (or a whole delivery), so the RFI gap detector keeps
// counting AUs.
let au_first = frame.part.is_none_or(|p| p.first);
let gap = au_first && client.note_frame_index(frame.frame_index);
// Park the receipt stamp (keyed by the pts the codec echoes) whenever the `decode`
// stage is consumed: the HUD, or the ABR decode signal (`measure_decode`). The
// HUD-only `received` point + host/network split stay gated on the overlay.
if stats.enabled() || measure_decode {
if (stats.enabled() || measure_decode) && frame.complete {
// Core reassembly-completion stamp (ABI v9), NOT the pull instant: stamping
// here would fold the hand-off queue wait into the network latency figure
// (a client-side standing backlog masquerading as network). 0 = older core.
@@ -482,7 +615,10 @@ fn feeder_loop(
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);
// On a parts stream the completing delivery carries only the AU's
// suffix — its offset restores the full AU byte count for bitrate.
let au_len = frame.part.map_or(0, |p| p.offset as usize) + frame.data.len();
stats.note_received(au_len, lat_us, clock_offset != 0);
if let Some(hostnet_us) = lat_us {
pending_split.push_back((frame.pts_ns, hostnet_us));
if pending_split.len() > PENDING_SPLIT_CAP {
@@ -490,6 +626,17 @@ fn feeder_loop(
}
}
while let Ok(t) = client.next_host_timing(Duration::ZERO) {
// Phase-lock closed-loop readout: the host's applied hold rides the
// 0xCF tail; log transitions (~1 Hz worst case — the host updates it
// once a second). None = a host without the tail (pre-phase-lock).
if t.applied_phase_ns != last_phase_ack {
log::info!(
target: "pf.phase",
"host applied_phase={:?}us",
t.applied_phase_ns.map(|n| n / 1000)
);
last_phase_ack = t.applied_phase_ns;
}
if let Some(i) = pending_split.iter().position(|&(p, _)| p == t.pts_ns)
{
let (_, hostnet_us) = pending_split.remove(i).unwrap();
@@ -520,9 +667,11 @@ fn dispatch_event(
free_inputs: &mut VecDeque<usize>,
ready: &mut Vec<OutputReady>,
fmt_dirty: &mut bool,
vsync_tick: &mut bool,
fatal: &mut bool,
gate: &mut ReanchorGate,
recovery_flags: &mut VecDeque<(u64, u32)>,
arrival_stamps: &mut Vec<i128>,
) -> bool {
match ev {
DecodeEvent::Au(f, gap) => {
@@ -531,9 +680,27 @@ fn dispatch_event(
if gap {
gate.arm(Instant::now());
}
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
if recovery_flags.len() > IN_FLIGHT_CAP {
recovery_flags.pop_front();
// One entry per AU (parts share the pts): the completing delivery carries it.
if f.complete {
recovery_flags.push_back((f.pts_ns / 1000, f.flags));
if recovery_flags.len() > IN_FLIGHT_CAP {
recovery_flags.pop_front();
}
}
// Phase-lock v3 sensor: the ARRIVAL stamp (reassembly completion, realtime) — the
// phase the host actually controls. The latch-based v2 sensor measured downstream
// of the decoder pipeline, which absorbed the host's actuation (on-glass 07-31).
// Phase sensor: AU completion is the arrival the host's hold actually moves —
// prefix parts would smear the phase toward the first slice's landing.
if f.complete {
arrival_stamps.push(if f.received_ns > 0 {
f.received_ns as i128
} else {
now_realtime_ns()
});
if arrival_stamps.len() > 256 {
arrival_stamps.remove(0);
}
}
pending_aus.push_back(f);
if pending_aus.len() > FRAME_PARK_CAP {
@@ -552,6 +719,7 @@ fn dispatch_event(
decoded_ns,
}),
DecodeEvent::FormatChanged => *fmt_dirty = true,
DecodeEvent::Vsync => *vsync_tick = true,
DecodeEvent::Error { fatal: f } => {
if f {
*fatal = true;
@@ -565,10 +733,35 @@ fn dispatch_event(
false
}
/// `AMEDIACODEC_BUFFER_FLAG_PARTIAL_FRAME` (NDK ≥ 26, gated by the Kotlin
/// `FEATURE_PartialFrame` probe): this input buffer is a PIECE of an AU — the codec assembles
/// pieces until a buffer WITHOUT the flag closes the AU.
const BUFFER_FLAG_PARTIAL_FRAME: u32 = 8;
/// The slice-progressive feed's open access unit: parts already queued into the codec under
/// [`BUFFER_FLAG_PARTIAL_FRAME`], awaiting the rest. Loop-local — a codec rebuild tears the
/// whole loop down, so the state can never outlive the codec instance it fed.
pub(super) struct PartFeed {
index: u32,
/// The AU byte offset the next part must carry — a mismatch means the hand-off dropped a
/// piece (memory cap / jump-to-live clear) and the AU is unrecoverable.
expected: usize,
/// The dead-close pts: an abandoned AU is CLOSED with an empty non-PARTIAL buffer at its
/// own pts — the codec then emits (concealed garbage) at that pts, which the reanchor
/// freeze gate withholds from glass while the keyframe request recovers the chain. No
/// mid-stream codec flush needed.
pts_us: u64,
}
/// 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; an AU larger than the buffer is DROPPED (+ a recovery keyframe requested) — a
/// truncated AU is corrupt input the decoder chews on silently, poisoning the reference chain.
///
/// Slice-progressive deliveries ([`Frame::part`]) feed as they arrive: every piece rides
/// [`BUFFER_FLAG_PARTIAL_FRAME`] except the AU's last, all at the AU's pts. `part_open` is the
/// continuity ledger — any break (gap, orphan, oversize) abandons the AU per [`PartFeed::pts_us`]'s
/// close contract and re-syncs at the next `first`.
fn feed_ready(
codec: &MediaCodec,
client: &NativeClient,
@@ -576,11 +769,49 @@ fn feed_ready(
free_inputs: &mut VecDeque<usize>,
fed: &mut u64,
oversized_dropped: &mut u64,
part_open: &mut Option<PartFeed>,
gate: &mut ReanchorGate,
) {
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 (first, last, offset) = match frame.part {
None => (true, true, 0usize),
Some(p) => (p.first, p.last, p.offset as usize),
};
// Continuity ledger. `continues` = this piece extends the open AU exactly;
// anything else with an AU open means that AU died mid-flight and must be closed
// (empty non-PARTIAL buffer at ITS pts) before this frame may touch the codec.
let continues = part_open
.as_ref()
.is_some_and(|o| frame.frame_index == o.index && offset == o.expected && !first);
if !continues {
if let Some(o) = part_open.take() {
// Spend THIS slot on the close; the current frame re-queues for the next one.
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, 0, o.pts_us, 0) {
log::warn!("decode: close of abandoned partial AU {}: {e}", o.index);
}
log::warn!(
"decode: partial AU {} abandoned mid-feed — closed empty, requesting keyframe",
o.index
);
// The close makes the codec emit concealed garbage at the dead pts — freeze it
// off the glass until the recovery keyframe re-anchors.
gate.arm(Instant::now());
let _ = client.request_keyframe();
pending_aus.push_front(frame);
continue;
}
// No AU open: an orphan non-first piece lost its head upstream — discard and
// re-sync at the next `first` (the recovery request rides the same loss).
if !first {
free_inputs.push_front(idx);
gate.arm(Instant::now());
let _ = client.request_keyframe();
continue;
}
}
let Some(dst) = codec.input_buffer(idx) else {
log::warn!("decode: input_buffer({idx}) returned None — dropping AU");
continue;
@@ -597,6 +828,16 @@ fn feed_ready(
*oversized_dropped
);
let _ = client.request_keyframe();
if frame.part.is_some() {
gate.arm(Instant::now());
// Pieces already queued can't be unqueued: poison the ledger so the next
// delivery mismatches and takes the close-empty path above.
*part_open = Some(PartFeed {
index: frame.frame_index,
expected: usize::MAX,
pts_us,
});
}
continue;
}
let n = au.len();
@@ -605,21 +846,44 @@ fn feed_ready(
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) {
let flags = if last { 0 } else { BUFFER_FLAG_PARTIAL_FRAME };
if let Err(e) = codec.queue_input_buffer_by_index(idx, 0, n, pts_us, flags) {
log::warn!("decode: queue_input_buffer_by_index: {e}");
if frame.part.is_some() && !last {
// The piece never reached the codec — same unrecoverable-AU shape as oversize.
*part_open = Some(PartFeed {
index: frame.frame_index,
expected: usize::MAX,
pts_us,
});
}
} else {
*fed += 1;
// `fed` counts ACCESS UNITS toward the HUD's fed/decoded balance — the closing
// piece (or a whole AU) bumps it.
if last {
*fed += 1;
}
*part_open = if last {
None
} else {
Some(PartFeed {
index: frame.frame_index,
expected: offset + n,
pts_us,
})
};
}
}
}
/// 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. The presented frame's `(pts, decoded stamp)` is parked in
/// `tracker` for the OnFrameRendered callback — the `display` stage's other endpoint. `ready` is
/// drained.
/// Route the ready outputs toward glass. With the timeline presenter (default): fold each output
/// through the re-anchor gate in pts order, hand the approved ones to the presenter's store
/// (newest-wins / smoothing FIFO — the actual release happens in `Presenter::pump`, budgeted and
/// timeline-timed), and release withheld concealment unrendered. Legacy (`arrival` sysprop):
/// present only the NEWEST ready output immediately and release the rest unrendered — the
/// original policy. 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.
#[allow(clippy::too_many_arguments)] // one call site; mirrors the sync loop's drain
fn present_ready(
codec: &MediaCodec,
@@ -630,6 +894,7 @@ fn present_ready(
in_flight: &Mutex<VecDeque<(u64, i128)>>,
clock_offset: i64,
tracker: &DisplayTracker,
presenter: &mut Option<Presenter>,
rendered: &mut u64,
discarded: &mut u64,
gate: &mut ReanchorGate,
@@ -657,31 +922,46 @@ fn present_ready(
}
}
// Fold EVERY output through the gate in pts (== decode) order — even the ones newest-wins discards —
// so the two-mark re-anchor count stays correct; the newest's verdict decides whether it reaches
// glass (`false` = withheld concealment; the SurfaceView keeps the last rendered frame frozen on).
// so the two-mark re-anchor count stays correct; a `false` verdict is withheld concealment (the
// SurfaceView keeps the last rendered frame frozen on).
let now = Instant::now();
let last = ready.len() - 1;
let mut skipped: u64 = 0;
for (i, o) in ready.drain(..).enumerate() {
let flags = take_flags(recovery_flags, o.pts_us);
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
let render = i == last && present;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => {
*rendered += 1;
if stats.enabled() {
tracker.note_rendered(o.pts_us, o.decoded_ns);
if let Some(p) = presenter.as_mut() {
for o in ready.drain(..) {
let flags = take_flags(recovery_flags, o.pts_us);
if gate.on_decoded(flags, false, now) == GateVerdict::Present {
let dropped = p.submit(codec, o.index, o.pts_us, o.decoded_ns);
skipped += dropped;
*discarded += dropped;
} else {
if let Err(e) = codec.release_output_buffer_by_index(o.index, false) {
log::warn!("decode: release_output_buffer_by_index({}): {e}", o.index);
}
}
Ok(()) => {
*discarded += 1;
skipped += 1;
}
Err(e) => {
log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}",
o.index
)
}
} else {
let last = ready.len() - 1;
for (i, o) in ready.drain(..).enumerate() {
let flags = take_flags(recovery_flags, o.pts_us);
let present = gate.on_decoded(flags, false, now) == GateVerdict::Present;
let render = i == last && present;
match codec.release_output_buffer_by_index(o.index, render) {
Ok(()) if render => {
*rendered += 1;
tracker.note_rendered(o.pts_us, o.decoded_ns, now_realtime_ns());
}
Ok(()) => {
*discarded += 1;
skipped += 1;
}
Err(e) => {
log::warn!(
"decode: release_output_buffer_by_index({}, {render}): {e}",
o.index
)
}
}
}
}
+29 -18
View File
@@ -35,32 +35,37 @@ pub(super) struct DisplayTracker {
/// loaded per callback so mid-stream re-syncs apply. Holding the handle (not the client)
/// keeps the leaked render-callback refcount from pinning the whole session alive.
clock_offset: Arc<AtomicI64>,
/// `(pts_us, decoded_real_ns)` of frames released with `render = true`, in release order,
/// awaiting their callback. Pushes are HUD-gated by the caller, so this stays empty (and the
/// callback early-outs) while the overlay is hidden.
rendered: Mutex<VecDeque<(u64, i128)>>,
/// Always-on latch/display accumulator for the presenter's 1 Hz `pf-present` line —
/// independent of the HUD gate, so a HUD-off A/B stays measurable from logcat.
meter: Arc<super::presenter::PresentMeter>,
/// `(pts_us, decoded_real_ns, released_real_ns)` of frames released with `render = true`, in
/// release order, awaiting their callback. Pushed on EVERY render (no HUD gate — the ring is
/// a 64-tuple bound and the latch metric wants to exist when nobody is watching).
rendered: Mutex<VecDeque<(u64, i128, i128)>>,
}
impl DisplayTracker {
pub(super) fn new(
stats: Arc<crate::stats::VideoStats>,
clock_offset: Arc<AtomicI64>,
meter: Arc<super::presenter::PresentMeter>,
) -> Arc<DisplayTracker> {
Arc::new(DisplayTracker {
stats,
clock_offset,
meter,
rendered: Mutex::new(VecDeque::new()),
})
}
/// Park one just-rendered frame's `(pts, decoded stamp)` for the render callback to pair.
/// Caller gates on the HUD being visible.
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128) {
/// Park one just-rendered frame's `(pts, decoded stamp, release stamp)` for the render
/// callback to pair — the release stamp is the latch metric's start (release→displayed).
pub(super) fn note_rendered(&self, pts_us: u64, decoded_ns: i128, released_ns: i128) {
let mut g = self
.rendered
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.push_back((pts_us, decoded_ns));
g.push_back((pts_us, decoded_ns, released_ns));
if g.len() > RENDERED_CAP {
g.pop_front(); // render callbacks stopped coming (allowed under load) — evict
}
@@ -152,36 +157,42 @@ unsafe extern "C" fn on_frame_rendered(
// `Arc::into_raw` pointer from `install_render_callback`, whose refcount is held for as long as
// the codec exists, and the codec is what delivers this call.
let t = unsafe { &*(userdata as *const DisplayTracker) };
if !t.stats.enabled() {
return; // HUD hidden — the ring is empty too (pushes are caller-gated)
}
let displayed_ns = now_realtime_ns() - (now_monotonic_ns() - system_nano as i128);
let pts_us = media_time_us.max(0) as u64;
// Pair the frame back to its release record, evicting older entries (their callbacks were
// dropped by the platform, or the entry predates a HUD toggle) — same monotonic-eviction
// discipline as `note_decoded_pts`.
let mut decoded_ns = None;
// dropped by the platform) — same monotonic-eviction discipline as `note_decoded_pts`.
let mut paired = None;
{
let mut g = t
.rendered
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
while let Some(&(p, d)) = g.front() {
while let Some(&(p, d, r)) = g.front() {
if p > pts_us {
break; // future frame — leave it for its own callback
}
g.pop_front();
if p == pts_us {
decoded_ns = Some(d);
paired = Some((d, r));
break;
}
}
}
// Clamped to (0, 10 s) like the e2e sample: a vendor's first render callbacks can carry a
// garbage `system_nano` (observed on-glass: an epoch-sized latch max on the session's first
// window), and one such sample would poison every max/percentile it lands in.
let clamp = |v: i128| (v > 0 && v < 10_000_000_000).then_some((v / 1000) as u64);
let display_us = paired.and_then(|(d, _)| clamp(displayed_ns - d));
let latch_us = paired.and_then(|(_, r)| clamp(displayed_ns - r));
// Always-on half: the presenter's pf-present line reads these with the HUD off.
t.meter.note_latch(latch_us);
if !t.stats.enabled() {
return; // HUD hidden — skip the skew math + the stats lock
}
let e2e_ns =
displayed_ns + t.clock_offset.load(Ordering::Relaxed) as i128 - pts_us as i128 * 1000;
let e2e_us = (e2e_ns > 0 && e2e_ns < 10_000_000_000).then_some((e2e_ns / 1000) as u64);
let display_us = decoded_ns.map(|d| ((displayed_ns - d).max(0) / 1000) as u64);
t.stats.note_displayed(e2e_us, display_us);
t.stats.note_displayed(e2e_us, display_us, latch_us);
}
/// React to an output-format change by signalling the stream's HDR dataspace on the Surface (SDR
+13
View File
@@ -9,8 +9,10 @@
mod async_loop;
mod display;
mod latency;
mod presenter;
mod setup;
mod sync_loop;
mod vsync;
use async_loop::run_async;
pub(crate) use setup::{codec_label, codec_mime};
@@ -106,6 +108,17 @@ pub(crate) struct DecodeOptions {
/// 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 user's presentation intent (`present_priority` setting): 0 = lowest latency
/// (newest-wins), 1 = smoothness (a small FIFO). Resolved by
/// [`presenter::PresentPriority::resolve`]; anything else = latency.
pub present_priority: i32,
/// The smoothness buffer depth (`smooth_buffer` setting): 0 = automatic (2), else 1..=3.
/// Only meaningful with `present_priority` = smooth.
pub smooth_buffer: i32,
/// The display mode's own refresh rate (Kotlin's `display.refreshRate` at stream start;
/// 0 = unknown) — the latch grid the presenter subdivides onto when the app's choreographer
/// stream is down-rated below the panel (see `vsync.rs`).
pub panel_hz: i32,
}
/// The decode entry point on the `pf-decode` thread: dispatches to the async or synchronous loop.
@@ -0,0 +1,475 @@
//! The timeline presenter — Android's port of the Apple client's stage-4 deadline discipline
//! (`clients/apple/.../Stage2Pipeline.swift`, the `.deadline` pacing):
//!
//! * a **newest-wins slot** (or a small smoothing FIFO, by user intent) between decode and
//! release, so a burst coalesces in the app — as an explicit, counted drop — instead of
//! queueing behind the display;
//! * a **glass budget of exactly one**: at most one undisplayed release in flight to
//! SurfaceFlinger, reopened on the clock-predicted latch (with a 100 ms stale force-open as
//! the liveness backstop, mirroring Apple's `PresentGate.staleAfter`). The BufferQueue can
//! hold at most the frame being scanned out plus one — a standing queue is unconstructible;
//! * a **timed release**: `AMediaCodec_releaseOutputBufferAtTime` targeting the platform's own
//! frame timeline (API 33+, via [`super::vsync`]), so the latch phase is deterministic instead
//! of inheriting network + decode jitter. On the 31/32 fallback the release is ASAP —
//! identical to the legacy path — and only the budget prediction uses the measured period.
//!
//! The legacy behaviour (release the newest ready buffer immediately, unbudgeted) remains
//! selectable at runtime: `adb shell setprop debug.punktfunk.presenter arrival` — the on-device
//! A/B needs no rebuild. The user-facing escape hatch stays the "Low-latency mode" master toggle
//! (off = the synchronous pre-overhaul loop, no presenter at all).
use ndk::media::media_codec::MediaCodec;
use std::collections::VecDeque;
use std::sync::Mutex;
use std::time::Instant;
use super::display::DisplayTracker;
use super::latency::now_realtime_ns;
use super::vsync::VsyncShared;
/// Submit-margin ahead of a timeline's EXPECTED PRESENT — SurfaceFlinger's own latch lead: the
/// released buffer must be in the BufferQueue by SF's wakeup for that vsync (a few ms before
/// present). A present closer than this is treated as missed and the next one is targeted. This
/// is deliberately NOT the timeline's `deadline` (which budgets for GPU rendering a video
/// buffer doesn't do — see `VsyncShared::next_target`); a too-tight gamble here presents one
/// vsync later, the exact cost the deadline gate paid on every frame.
///
/// 2.5 ms: SF's latch runs ~1-2 ms before present on modern devices (its `sfOffset`), and the
/// release itself is a binder call well under a ms. 4 ms measured latch p50 8-10; each ms cut
/// here is a ms off every frame's display stage. If a device misses at this margin the `paced`
/// counter shows it (a miss presents one vsync later, coalescing the next frame) — that is the
/// signal to widen, not stutter.
const LATCH_MARGIN_NS: i64 = 2_500_000;
/// `debug.punktfunk.latch_margin_us` (0..=8000 µs): PIN the submit margin for a sweep —
/// setprop + stream restart, no rebuild. Unset/invalid = `None` = the adaptive default.
fn latch_margin_ns() -> Option<i64> {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.latch_margin_us".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
if n > 0 {
if let Ok(us) = std::str::from_utf8(&buf[..n as usize])
.unwrap_or("")
.trim()
.parse::<i64>()
{
if (0..=8_000).contains(&us) {
return Some(us * 1_000);
}
}
}
None
}
/// The budget's liveness backstop: a release whose predicted latch never seems to arrive
/// (clock glitch, mode switch) force-reopens the budget this long after the release, counted in
/// `forced` — reads 0 on healthy systems (Apple's `PresentGate.staleAfter`, same value).
const STALE_REOPEN_NS: i64 = 100_000_000;
/// Fallback latch-prediction period while the vsync clock is unmeasured/absent: one 120 Hz frame.
const FALLBACK_PERIOD_NS: i64 = 8_333_333;
/// The user's presentation intent — the Apple client's `PresentPriority`, same resolution rules:
/// anything but an explicit "smooth" is latency; a smooth buffer outside 1..=3 becomes 2.
#[derive(Clone, Copy, PartialEq)]
pub(crate) enum PresentPriority {
/// Newest-wins, release the instant the budget opens. The default.
Latency,
/// A small FIFO (1..=3 frames) drained one per vsync: jitter absorbed at one refresh of
/// added display latency per slot, which the metrics show rather than hide.
Smooth { buffer: usize },
}
impl PresentPriority {
/// From the JNI ints (`presentPriority` 0 = latency / 1 = smooth; `smoothBuffer` 0 = auto).
pub(crate) fn resolve(priority: i32, buffer: i32) -> PresentPriority {
if priority != 1 {
return PresentPriority::Latency;
}
let b = if (1..=3).contains(&buffer) {
buffer as usize
} else {
2
};
PresentPriority::Smooth { buffer: b }
}
}
/// One decoded output buffer held for presentation.
struct HeldFrame {
index: usize,
pts_us: u64,
/// The output callback's `CLOCK_REALTIME` stamp — the pace metric's start (decoded→release).
decoded_ns: i128,
}
/// The one-in-flight glass budget.
struct InFlight {
/// Monotonic instant the budget reopens: the release target's expected present (clock), or
/// `release + period` on the fallback path.
reopen_at_ns: i64,
released_at_ns: i64,
}
/// Latch samples + display confirms recorded by the `OnFrameRendered` callback thread, drained by
/// the presenter's 1 Hz `pf-present` line. Always on (independent of the HUD) — this is what makes
/// a HUD-off wireless A/B readable from logcat.
pub(super) struct PresentMeter {
inner: Mutex<PresentMeterInner>,
}
struct PresentMeterInner {
latch_us: Vec<u64>,
displays: u64,
}
impl PresentMeter {
pub(super) fn new() -> PresentMeter {
PresentMeter {
inner: Mutex::new(PresentMeterInner {
latch_us: Vec::with_capacity(256),
displays: 0,
}),
}
}
/// One displayed frame's release→displayed latch, µs. Callback thread; poison-proof.
pub(super) fn note_latch(&self, latch_us: Option<u64>) {
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.displays += 1;
if let Some(l) = latch_us {
if g.latch_us.len() < 4096 {
g.latch_us.push(l);
}
}
}
fn drain(&self) -> (Vec<u64>, u64) {
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let displays = g.displays;
g.displays = 0;
(std::mem::take(&mut g.latch_us), displays)
}
}
/// p50/max of an unsorted µs sample vec, in ms. (0, 0) when empty.
fn p50_max_ms(mut v: Vec<u64>) -> (f64, f64) {
if v.is_empty() {
return (0.0, 0.0);
}
v.sort_unstable();
let p50 = v[v.len() / 2] as f64 / 1000.0;
let max = *v.last().unwrap() as f64 / 1000.0;
(p50, max)
}
pub(super) struct Presenter {
/// 0 = newest-wins; 1..=3 = smoothing FIFO capacity.
fifo_capacity: usize,
frames: VecDeque<HeldFrame>,
/// FIFO preroll: `take` withholds until the buffer filled to capacity once, re-armed on a dry
/// run — the Apple `FrameStore` semantics (headroom never builds without it).
prerolled: bool,
inflight: Option<InFlight>,
/// A vsync arrived since the last release — the FIFO's one-per-refresh drain pace.
vsync_tick: bool,
// -- 1 Hz pf-present window, always on --
released: u64,
paced_drops: u64,
no_budget: u64,
forced: u64,
dry: u64,
pace_us: Vec<u64>,
last_flush: Instant,
/// The live submit margin. Starts at 0 (P2e on-glass: SurfaceFlinger latched every
/// release with NO lead on the NP3 — the fixed 2.5 ms was pure display latency) and
/// widens by measurement: `paced` misses in a 1 Hz window push it +500 µs toward
/// [`LATCH_MARGIN_NS`], the pre-sweep known-safe ceiling. A sysprop override PINS it.
margin_ns: i64,
/// `debug.punktfunk.latch_margin_us` was set — the margin is pinned, never adapted.
margin_pinned: bool,
}
impl Presenter {
pub(super) fn new(priority: PresentPriority) -> Presenter {
let pinned = latch_margin_ns();
let (margin_ns, margin_pinned) = match pinned {
Some(ns) => (ns, true),
None => (0, false),
};
log::info!(
"presenter: latch margin {}us{}",
margin_ns / 1_000,
if margin_pinned {
" (debug.punktfunk.latch_margin_us pin)"
} else {
" (adaptive — widens on latch misses)"
}
);
Presenter {
fifo_capacity: match priority {
PresentPriority::Latency => 0,
PresentPriority::Smooth { buffer } => buffer,
},
frames: VecDeque::new(),
prerolled: false,
inflight: None,
vsync_tick: false,
released: 0,
paced_drops: 0,
no_budget: 0,
forced: 0,
dry: 0,
pace_us: Vec::with_capacity(256),
last_flush: Instant::now(),
margin_ns,
margin_pinned,
}
}
/// A vsync pulse from the clock thread's event — the retry tick for a parked frame and the
/// FIFO's drain pace.
pub(super) fn on_vsync(&mut self) {
self.vsync_tick = true;
}
/// Accept one decoded, gate-approved output buffer. Newest-wins evicts everything older
/// (released unrendered — the explicit, counted drop); the FIFO evicts its oldest past
/// capacity. Returns how many frames were dropped by the policy (the HUD's `skipped`).
pub(super) fn submit(
&mut self,
codec: &MediaCodec,
index: usize,
pts_us: u64,
decoded_ns: i128,
) -> u64 {
let mut dropped = 0u64;
if self.fifo_capacity == 0 {
while let Some(stale) = self.frames.pop_front() {
release_unrendered(codec, stale.index);
dropped += 1;
}
}
self.frames.push_back(HeldFrame {
index,
pts_us,
decoded_ns,
});
if self.fifo_capacity > 0 && self.frames.len() > self.fifo_capacity {
if let Some(stale) = self.frames.pop_front() {
release_unrendered(codec, stale.index);
dropped += 1;
}
}
self.paced_drops += dropped;
dropped
}
/// The present decision point — run on every loop pass (frame arrivals, vsync ticks, and the
/// 5 ms housekeeping wake all land here). Releases AT MOST one frame (the budget). Returns
/// `true` when a frame was released to glass this call.
#[allow(clippy::too_many_arguments)] // one call site; the seams are the point
pub(super) fn pump(
&mut self,
codec: &MediaCodec,
clock: Option<&VsyncShared>,
tracker: &DisplayTracker,
stats: &crate::stats::VideoStats,
now_mono_ns: i64,
) -> bool {
// Budget bookkeeping first: reopen on the predicted latch, force-open on the backstop.
if let Some(f) = &self.inflight {
if now_mono_ns >= f.reopen_at_ns {
self.inflight = None;
} else if now_mono_ns - f.released_at_ns > STALE_REOPEN_NS {
self.forced += 1;
self.inflight = None;
}
}
// Pick the frame this pump may release.
let frame = if self.fifo_capacity == 0 {
self.frames.pop_back() // submit() kept it a single slot; back == the newest
} else {
// FIFO: drain exactly one frame per vsync tick, after preroll; a drain tick that
// finds the buffer dry re-arms preroll (the Apple `FrameStore` underflow semantics —
// the previous frame persists on glass, a repeat by omission, while headroom
// rebuilds). Everything is gated on the tick so an idle stream neither counts
// underflows nor churns the preroll flag 200×/s.
if !self.vsync_tick {
return false;
}
if !self.prerolled {
if self.frames.len() < self.fifo_capacity {
return false;
}
self.prerolled = true;
}
if self.frames.is_empty() {
self.prerolled = false;
self.dry += 1;
self.vsync_tick = false; // this tick's drain ran (and found nothing)
return false;
}
self.frames.pop_front()
};
let Some(frame) = frame else { return false };
if self.inflight.is_some() {
// Budget closed — park it back; a fresher submit replaces it (newest-wins), the next
// vsync tick / loop pass retries the pairing.
self.no_budget += 1;
match self.fifo_capacity {
0 => self.frames.push_back(frame),
_ => self.frames.push_front(frame),
}
return false;
}
// Release: timeline-timed when the clock has one, ASAP otherwise.
let target = clock.and_then(|c| c.next_target(now_mono_ns, self.margin_ns));
let released = match target {
Some(t) => codec
.release_output_buffer_at_time_by_index(frame.index, t.expected_present_ns)
.map_err(|e| log::warn!("presenter: release_at_time({}): {e}", frame.index)),
None => codec
.release_output_buffer_by_index(frame.index, true)
.map_err(|e| log::warn!("presenter: release({}): {e}", frame.index)),
};
self.vsync_tick = false;
if released.is_err() {
return false; // the buffer is gone either way; nothing to book-keep
}
let period = clock.map(|c| c.period_ns()).filter(|&p| p > 0);
// Reopen at SurfaceFlinger's LATCH for the targeted vsync (expected present minus the
// latch lead) — the instant SF consumes the queued buffer and the slot frees, so the
// next release can target the NEXT refresh. Not the platform `deadline` (with the
// aggressive present gate it can already be in the past — an instant reopen would let
// two releases pile onto the same vsync) and not the present time itself (a period too
// late — it would cap the sustainable rate at roughly half the panel).
let reopen_at_ns = target
.map(|t| t.expected_present_ns - self.margin_ns)
.unwrap_or(now_mono_ns + period.unwrap_or(FALLBACK_PERIOD_NS));
self.inflight = Some(InFlight {
reopen_at_ns,
released_at_ns: now_mono_ns,
});
self.released += 1;
let release_real_ns = now_realtime_ns();
let pace_us = ((release_real_ns - frame.decoded_ns).max(0) / 1000) as u64;
if self.pace_us.len() < 4096 {
self.pace_us.push(pace_us);
}
stats.note_release(pace_us);
tracker.note_rendered(frame.pts_us, frame.decoded_ns, release_real_ns);
true
}
/// Release every held buffer unrendered — the teardown path, BEFORE `codec.stop()`.
pub(super) fn release_all(&mut self, codec: &MediaCodec) {
while let Some(f) = self.frames.pop_front() {
release_unrendered(codec, f.index);
}
self.inflight = None;
}
/// The 1 Hz `pf-present` logcat mirror (target `pf.present`) — the Apple client's Console
/// `pf-present` line, so a HUD-off on-device A/B is readable wirelessly:
/// `released` (to glass) / `displays` (OnFrameRendered confirms) / `paced` (policy drops) /
/// `noBudget` (waits on the closed budget) / `forced` (stale force-opens — 0 when healthy) /
/// `qDry` (FIFO underflows) / `pace` (decoded→release) / `latch` (release→displayed) /
/// `vsync` (the measured panel period).
///
/// Returns this window's CIRCULAR latch statistics `(vector-mean latch ns mod panel period,
/// coherence ‰)` when a window actually flushed — the phase-lock reporter's v2 error signal
/// (design/phase-locked-capture.md §6; the v1 median was immovable under jitter).
pub(super) fn flush_log(
&mut self,
meter: &PresentMeter,
clock: Option<&VsyncShared>,
) -> Option<(u64, u16)> {
if self.last_flush.elapsed() < std::time::Duration::from_secs(1) {
return None;
}
self.last_flush = Instant::now();
let (latch, displays) = meter.drain();
if self.released == 0 && displays == 0 {
return None; // idle stream — nothing worth a line
}
let (pace_p50, pace_max) = p50_max_ms(std::mem::take(&mut self.pace_us));
let circ = clock.and_then(|c| {
punktfunk_core::phase::circular_latch(&latch, c.panel_period_ns().max(c.period_ns()))
});
let (latch_p50, latch_max) = p50_max_ms(latch);
let period_ms = clock.map(|c| c.period_ns() as f64 / 1e6).unwrap_or(0.0);
let panel_ms = clock
.map(|c| c.panel_period_ns() as f64 / 1e6)
.unwrap_or(0.0);
log::info!(
target: "pf.present",
"released={} displays={} paced={} noBudget={} forced={} qDry={} \
paceMs p50={:.2} max={:.2} latchMs p50={:.2} max={:.2} circ={:.2}ms coh={} \
vsyncMs={:.2} panelMs={:.2}",
self.released,
displays,
self.paced_drops,
self.no_budget,
self.forced,
self.dry,
pace_p50,
pace_max,
latch_p50,
latch_max,
circ.map(|(m, _)| m as f64 / 1e6).unwrap_or(0.0),
circ.map(|(_, c)| c).unwrap_or(0),
period_ms,
panel_ms,
);
self.released = 0;
// Margin adaptation: repeated latch misses in one window (a miss presents a vsync
// late and coalesces the next frame into `paced`) mean this device's SF does need
// lead — widen toward the pre-sweep ceiling. One-way by design: a margin that once
// proved necessary is never re-gambled mid-stream (the next stream restarts at 0).
if !self.margin_pinned && self.paced_drops > 2 && self.margin_ns < LATCH_MARGIN_NS {
self.margin_ns = (self.margin_ns + 500_000).min(LATCH_MARGIN_NS);
log::warn!(
"presenter: {} latch misses in 1s — margin widened to {}us",
self.paced_drops,
self.margin_ns / 1_000
);
}
self.paced_drops = 0;
self.no_budget = 0;
self.forced = 0;
self.dry = 0;
circ
}
}
fn release_unrendered(codec: &MediaCodec, index: usize) {
if let Err(e) = codec.release_output_buffer_by_index(index, false) {
log::warn!("presenter: release_output_buffer({index}, false): {e}");
}
}
/// `debug.punktfunk.presenter` sysprop: `arrival` = the legacy release-immediately path,
/// anything else / unset = the timeline presenter. The rebuild-free on-device A/B lever.
pub(super) fn presenter_disabled_by_sysprop() -> bool {
let mut buf = [0u8; 92]; // PROP_VALUE_MAX
// SAFETY: __system_property_get with a valid name + PROP_VALUE_MAX buffer is always safe.
let n = unsafe {
libc::__system_property_get(
c"debug.punktfunk.presenter".as_ptr(),
buf.as_mut_ptr().cast(),
)
};
n > 0 && &buf[..n as usize] == b"arrival"
}
+14 -6
View File
@@ -180,8 +180,14 @@ pub(super) fn boost_thread_priority() {
/// 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.
/// - Otherwise: `ANativeWindow_setFrameRate` (**API 30**) — the seamless-preferred hint for
/// phones/tablets and the universal fallback.
///
/// Both paths pass `compatibility = FIXED_SOURCE` (1): the stream is fixed-rate video content the
/// client cannot re-pace, which is exactly what that value declares — `DEFAULT` (0) told the
/// platform the app could adapt to whatever rate it picked, an invitation some OEM refresh
/// governors accepted by simply not switching. (The window-level `preferredDisplayModeId` pin in
/// `MainActivity.setStreamDisplayMode` is the phone-side belt to this braces.)
///
/// Returns `true` when the platform accepted a hint; `false` on API < 30 (symbols absent) or a
/// decline.
@@ -200,8 +206,10 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
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.
// ANATIVEWINDOW_FRAME_RATE_COMPATIBILITY_FIXED_SOURCE — fixed-rate video content.
const FIXED_SOURCE: i8 = 1;
// TV: prefer the API-31 change-strategy form to force the mode switch (strategy 1 =
// ALWAYS). Absent on API 30 ⇒ fall through to the 2-arg hint below.
if is_tv {
let sym = libc::dlsym(
lib,
@@ -209,7 +217,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
);
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;
return set(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE, 1) == 0;
}
}
let sym = libc::dlsym(lib, c"ANativeWindow_setFrameRate".as_ptr());
@@ -217,7 +225,7 @@ pub(super) fn try_set_frame_rate(window: &NativeWindow, frame_rate: f32, is_tv:
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
set_frame_rate(window.ptr().as_ptr().cast(), frame_rate, FIXED_SOURCE) == 0
}
}
+10 -2
View File
@@ -44,6 +44,10 @@ pub(super) fn run_sync(
ll_feature,
low_latency_mode,
is_tv,
// The timeline presenter lives in the async loop only; this loop IS the escape hatch.
present_priority: _,
smooth_buffer: _,
panel_hz: _,
} = opts;
boost_thread_priority();
let mode = client.mode();
@@ -181,7 +185,11 @@ pub(super) fn run_sync(
// render = true are parked in the tracker; the OnFrameRendered callback pairs them with
// SurfaceFlinger's render timestamp. `render_cb` is the callback's leaked Arc refcount,
// reclaimed after the codec is dropped below.
let tracker = DisplayTracker::new(stats.clone(), clock_offset.clone());
let tracker = DisplayTracker::new(
stats.clone(),
clock_offset.clone(),
std::sync::Arc::new(super::presenter::PresentMeter::new()),
);
let render_cb = install_render_callback(&codec, &tracker);
// Receipt timestamps keyed by the pts we queue into the codec, so the decoded point (output-
// buffer dequeue — MediaCodec round-trips presentationTimeUs) can be paired back to its receipt
@@ -598,7 +606,7 @@ fn drain(
Ok(()) if held_present => {
rendered = 1;
if let Some((pts_us, decoded_ns)) = meta {
tracker.note_rendered(pts_us, decoded_ns);
tracker.note_rendered(pts_us, decoded_ns, super::latency::now_realtime_ns());
}
}
Ok(()) => discarded += 1, // held off the screen — awaiting a clean re-anchor
+448
View File
@@ -0,0 +1,448 @@
//! The vsync clock behind the timeline presenter: an `AChoreographer` thread publishing the
//! panel's vsync grid + upcoming frame timelines, and pulsing the decode loop's event channel so
//! a frame parked on a closed glass budget gets its retry tick.
//!
//! On API 33+ the thread rides `AChoreographer_postVsyncCallback`, whose callback payload carries
//! the platform's FRAME TIMELINES — for each upcoming refresh, when SurfaceFlinger expects to
//! present and the deadline by which a frame must be submitted to make it. That pair is exactly
//! what `AMediaCodec_releaseOutputBufferAtTime` wants as its target. On 31/32 the older
//! `postFrameCallback64` supplies only the vsync instant; the presenter then releases ASAP
//! (identical to the legacy path) and uses the measured period purely to predict the latch for
//! its glass budget.
//!
//! Every `AChoreographer_*` symbol is dlsym-resolved from `libandroid.so` (mirrors
//! [`super::setup::try_set_frame_rate`]): several sit above the crate's API floor, and one hard
//! import of a too-new symbol fails `System.loadLibrary` on every older device.
//!
//! Started LAZILY on the first decoded frame (the Apple deadline presenter's bootstrap lesson:
//! an eagerly started clock ticks uselessly for the whole connect window), stopped + joined via
//! [`VsyncClock`]'s `Drop`.
use std::ffi::c_void;
use std::sync::atomic::{AtomicBool, AtomicI64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
/// `CLOCK_MONOTONIC` now in nanoseconds — the clock AChoreographer stamps its timelines on and
/// the one `AMediaCodec_releaseOutputBufferAtTime` compares against (`System.nanoTime` basis).
/// Distinct from the stats path's `CLOCK_REALTIME`: presenter scheduling stays monotonic.
pub(super) fn now_monotonic_ns() -> i64 {
let mut ts = libc::timespec {
tv_sec: 0,
tv_nsec: 0,
};
// SAFETY: `clock_gettime` with a valid out-pointer is an always-safe syscall.
unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut ts) };
// Explicit widening: timespec's fields are 32-bit on armv7 (time_t/c_long).
ts.tv_sec as i64 * 1_000_000_000 + ts.tv_nsec as i64
}
/// One upcoming frame timeline (API 33+ payload): when SurfaceFlinger expects to present the
/// frame, and the last instant it can be submitted to make that present. Monotonic ns.
#[derive(Clone, Copy)]
pub(super) struct FrameTimeline {
pub expected_present_ns: i64,
pub deadline_ns: i64,
}
/// State the choreographer thread publishes and the decode loop reads. All monotonic ns.
pub(super) struct VsyncShared {
stop: AtomicBool,
/// The latest vsync callback's frame time (0 = no callback yet).
last_vsync_ns: AtomicI64,
/// Estimated vsync period (EMA over callback deltas / timeline spacing; 0 = unmeasured).
///
/// ⚠ This is the APP's render rate, not necessarily the panel's: Android down-rates a
/// process's vsync stream (frame-rate categories / per-uid overrides), so a quiet UI can be
/// served 60 Hz callbacks while the panel scans at 120 (observed on-glass, A024). Pacing
/// video to THIS rate would cap the stream — hence `panel_period_ns` + the subdivision in
/// [`Self::next_target`].
period_ns: AtomicI64,
/// The panel's own refresh period (from the display mode Kotlin resolved at stream start;
/// 0 = unknown). The grid SurfaceFlinger actually latches on.
panel_period_ns: AtomicI64,
/// Callback count, for the one-shot cadence diagnostic log.
ticks: std::sync::atomic::AtomicU32,
/// The latest callback's upcoming timelines, soonest first. Empty on the 31/32 fallback.
timelines: Mutex<Vec<FrameTimeline>>,
}
impl VsyncShared {
/// The measured vsync period, or 0 while unmeasured.
pub(super) fn period_ns(&self) -> i64 {
self.period_ns.load(Ordering::Relaxed)
}
/// The panel's own refresh period (0 = unknown) — for the pf-present line's decomposition.
pub(super) fn panel_period_ns(&self) -> i64 {
self.panel_period_ns.load(Ordering::Relaxed)
}
/// The release target for a frame submitted at `now`: the earliest stored timeline whose
/// EXPECTED PRESENT is still `margin` away, extrapolated forward by whole periods once the
/// stored set has aged out (timelines refresh once per vsync callback; a frame can decode
/// anywhere inside that window). `None` on the 31/32 fallback — the caller releases ASAP.
///
/// Gated on `expected_present`, NOT the timeline's `deadline`, on purpose: the deadline
/// budgets for GPU rendering the app has yet to submit (`presDeadline` — 11.3 ms on the
/// A024, more than a full 120 Hz period), but a video buffer is already fully rendered —
/// the only real constraint is SurfaceFlinger's own latch lead, which is what the caller's
/// `margin` represents. Targeting by deadline cost every frame an extra refresh of waiting
/// (measured: latch p50 ~21 ms vs the ~2-interval floor); a mis-gamble here just means the
/// frame presents one vsync later — exactly what the conservative gate always paid.
///
/// The picked target is then SUBDIVIDED onto the panel grid: the platform reports timelines
/// at the app's assigned render rate, but the panel latches at its own — when the app is
/// down-rated (60 Hz callbacks on a 120 Hz panel) the reported timelines are a whole panel
/// period apart or more, and pacing to them would cap the video. Pulling the target earlier
/// by whole panel periods (while its present still clears the margin) restores the true
/// grid; when callbacks run at the panel rate the pull condition is never true and this is
/// a no-op.
pub(super) fn next_target(&self, now_ns: i64, margin_ns: i64) -> Option<FrameTimeline> {
let mut t = {
let g = self
.timelines
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let found = g
.iter()
.find(|t| t.expected_present_ns > now_ns + margin_ns)
.copied();
match found {
Some(t) => t,
None => {
let last = g.last().copied()?;
let period = self.period_ns();
if period <= 0 {
return None;
}
// All stored timelines have passed — step the last one forward whole
// periods until its present clears `now + margin` again.
let behind = (now_ns + margin_ns).saturating_sub(last.expected_present_ns);
let k = behind / period + 1;
FrameTimeline {
expected_present_ns: last.expected_present_ns + k * period,
deadline_ns: last.deadline_ns + k * period,
}
}
}
};
let panel = self.panel_period_ns.load(Ordering::Relaxed);
if panel > 0 {
while t.expected_present_ns - panel > now_ns + margin_ns {
t.deadline_ns -= panel;
t.expected_present_ns -= panel;
}
}
Some(t)
}
}
// ---- dlsym'd AChoreographer surface ----
type PostFrameCallback64 =
unsafe extern "C" fn(*mut c_void, unsafe extern "C" fn(i64, *mut c_void), *mut c_void);
type PostVsyncCallback = unsafe extern "C" fn(
*mut c_void,
unsafe extern "C" fn(*const c_void, *mut c_void),
*mut c_void,
);
struct ChoreoApi {
get_instance: unsafe extern "C" fn() -> *mut c_void,
/// API 33: vsync callback with frame-timeline payload. Preferred.
post_vsync: Option<PostVsyncCallback>,
/// API 29 fallback: frame callback with only the vsync instant.
post_frame64: Option<PostFrameCallback64>,
// AChoreographerFrameCallbackData accessors (API 33; present iff `post_vsync` is).
fcd_frame_time: Option<unsafe extern "C" fn(*const c_void) -> i64>,
fcd_timelines_len: Option<unsafe extern "C" fn(*const c_void) -> usize>,
fcd_preferred_index: Option<unsafe extern "C" fn(*const c_void) -> usize>,
fcd_expected_present: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
fcd_deadline: Option<unsafe extern "C" fn(*const c_void, usize) -> i64>,
}
impl ChoreoApi {
/// Resolve from `libandroid.so`. `None` when even the baseline symbols are missing.
fn resolve() -> Option<ChoreoApi> {
// SAFETY: dlopen of the always-mapped libandroid.so (refcount bump, never closed); each
// dlsym is null-checked before the transmute to its fn-pointer type.
unsafe {
let lib = libc::dlopen(c"libandroid.so".as_ptr(), libc::RTLD_NOW);
if lib.is_null() {
return None;
}
let sym = |name: &std::ffi::CStr| {
let p = libc::dlsym(lib, name.as_ptr());
(!p.is_null()).then_some(p)
};
let get_instance = sym(c"AChoreographer_getInstance")?;
let post_vsync = sym(c"AChoreographer_postVsyncCallback");
let post_frame64 = sym(c"AChoreographer_postFrameCallback64");
post_vsync.or(post_frame64)?; // neither post entry point — no clock on this device
Some(ChoreoApi {
get_instance: std::mem::transmute::<
*mut c_void,
unsafe extern "C" fn() -> *mut c_void,
>(get_instance),
post_vsync: post_vsync.map(|p| std::mem::transmute::<*mut c_void, PostVsyncCallback>(p)),
post_frame64: post_frame64
.map(|p| std::mem::transmute::<*mut c_void, PostFrameCallback64>(p)),
fcd_frame_time: sym(c"AChoreographerFrameCallbackData_getFrameTimeNanos").map(|p| {
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> i64>(p)
}),
fcd_timelines_len: sym(c"AChoreographerFrameCallbackData_getFrameTimelinesLength")
.map(|p| {
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(
p,
)
}),
fcd_preferred_index: sym(
c"AChoreographerFrameCallbackData_getPreferredFrameTimelineIndex",
)
.map(|p| {
std::mem::transmute::<*mut c_void, unsafe extern "C" fn(*const c_void) -> usize>(p)
}),
fcd_expected_present: sym(
c"AChoreographerFrameCallbackData_getFrameTimelineExpectedPresentationTimeNanos",
)
.map(|p| {
std::mem::transmute::<
*mut c_void,
unsafe extern "C" fn(*const c_void, usize) -> i64,
>(p)
}),
fcd_deadline: sym(c"AChoreographerFrameCallbackData_getFrameTimelineDeadlineNanos")
.map(|p| {
std::mem::transmute::<
*mut c_void,
unsafe extern "C" fn(*const c_void, usize) -> i64,
>(p)
}),
})
}
}
}
/// Everything a callback invocation needs. Owned by the choreographer thread's stack; callbacks
/// only ever fire inside that thread's looper poll, so the borrow can't outlive the thread.
struct CallbackCtx {
api: ChoreoApi,
choreographer: *mut c_void,
shared: Arc<VsyncShared>,
on_tick: Box<dyn Fn() + Send>,
}
impl CallbackCtx {
/// Common tail of both callback flavours: update the grid estimate, publish, pulse, re-arm.
fn tick(&self, frame_time_ns: i64, timelines: Vec<FrameTimeline>) {
let prev = self
.shared
.last_vsync_ns
.swap(frame_time_ns, Ordering::Relaxed);
// Panel-grid learner: timeline spacing is SurfaceFlinger's own grid, and the finest
// spacing ever observed is the panel's true period — trustworthy where the configured
// value is not (under a per-uid frame-rate override, `Display.getRefreshRate` REPORTS
// THE OVERRIDE, observed on-glass: a 120 Hz panel read back as 60 while early timelines
// ran at 8.28 ms). Corrects DOWNWARD only: subdividing onto a finer real grid is always
// valid, widening on a later down-rated window never is.
if timelines.len() >= 2 {
let spacing = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
if (2_000_000..=42_000_000).contains(&spacing) {
let cur = self.shared.panel_period_ns.load(Ordering::Relaxed);
if cur == 0 || spacing < cur - 200_000 {
self.shared
.panel_period_ns
.store(spacing, Ordering::Relaxed);
}
}
}
// One-shot cadence diagnostic (3rd tick, once deltas exist): the callback cadence vs the
// panel period is exactly the down-rating question, and this line answers it on-glass.
if self.shared.ticks.fetch_add(1, Ordering::Relaxed) == 2 {
let spacing = if timelines.len() >= 2 {
timelines[1].expected_present_ns - timelines[0].expected_present_ns
} else {
0
};
log::info!(
"vsync: cadence Δ={:.2}ms timelines={} spacing={:.2}ms panel={:.2}ms",
if prev > 0 {
(frame_time_ns - prev) as f64 / 1e6
} else {
0.0
},
timelines.len(),
spacing as f64 / 1e6,
self.shared.panel_period_ns.load(Ordering::Relaxed) as f64 / 1e6,
);
}
// Period: prefer timeline spacing (exact, straight from the platform), else the delta of
// successive callbacks (jittery — EMA'd), clamped to sane panel rates (24..500 Hz).
let mut period = 0i64;
if timelines.len() >= 2 {
period = timelines[1].expected_present_ns - timelines[0].expected_present_ns;
} else if prev > 0 {
period = frame_time_ns - prev;
}
if (2_000_000..=42_000_000).contains(&period) {
let old = self.shared.period_ns.load(Ordering::Relaxed);
let smoothed = if old > 0 {
(old * 7 + period) / 8
} else {
period
};
self.shared.period_ns.store(smoothed, Ordering::Relaxed);
}
if !timelines.is_empty() {
let mut g = self
.shared
.timelines
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
*g = timelines;
}
(self.on_tick)();
if !self.shared.stop.load(Ordering::Relaxed) {
self.repost();
}
}
fn repost(&self) {
// SAFETY: `choreographer` is this thread's instance; the ctx pointer stays valid for the
// thread's life and callbacks only fire on this thread (see the struct doc).
unsafe {
let ud = self as *const CallbackCtx as *mut c_void;
if let Some(post) = self.api.post_vsync {
post(self.choreographer, on_vsync, ud);
} else if let Some(post) = self.api.post_frame64 {
post(self.choreographer, on_frame64, ud);
}
}
}
}
/// API 33+ trampoline: harvest the frame timelines, then the common tick. Panic-free (an unwind
/// out of an `extern "C"` fn aborts).
unsafe extern "C" fn on_vsync(data: *const c_void, ud: *mut c_void) {
// SAFETY: `ud` is the thread's `CallbackCtx`, alive for the whole poll loop (see struct doc).
let ctx = unsafe { &*(ud as *const CallbackCtx) };
let api = &ctx.api;
let (mut frame_time, mut timelines) = (now_monotonic_ns(), Vec::new());
// SAFETY: `data` is the platform's callback payload, valid for this invocation; the accessors
// were resolved together with `post_vsync` (same API level) and are only called when present.
unsafe {
if let Some(f) = api.fcd_frame_time {
frame_time = f(data);
}
if let (Some(len_f), Some(pref_f), Some(exp_f), Some(dl_f)) = (
api.fcd_timelines_len,
api.fcd_preferred_index,
api.fcd_expected_present,
api.fcd_deadline,
) {
let len = len_f(data).min(8);
// From the PREFERRED index on: earlier timelines are ones the platform already
// considers missed for a frame starting now.
let start = pref_f(data).min(len);
timelines = (start..len)
.map(|i| FrameTimeline {
expected_present_ns: exp_f(data, i),
deadline_ns: dl_f(data, i),
})
.collect();
}
}
ctx.tick(frame_time, timelines);
}
/// API 29 fallback trampoline: vsync instant only.
unsafe extern "C" fn on_frame64(frame_time_ns: i64, ud: *mut c_void) {
// SAFETY: `ud` is the thread's `CallbackCtx` (see `on_vsync`).
let ctx = unsafe { &*(ud as *const CallbackCtx) };
ctx.tick(frame_time_ns, Vec::new());
}
/// The clock: a dedicated looper thread the choreographer calls back on. Dropping stops + joins.
pub(super) struct VsyncClock {
shared: Arc<VsyncShared>,
join: Option<std::thread::JoinHandle<()>>,
}
impl VsyncClock {
/// Spawn the choreographer thread. `on_tick` fires once per vsync ON THAT THREAD — it must
/// only do something cheap and `Send` (the decode loop passes an event-channel send).
/// `panel_hz` is the display mode's own refresh rate (0 = unknown), the latch grid that
/// [`VsyncShared::next_target`] subdivides onto. `None` when the platform surface is missing
/// (very old device) — the presenter then runs clock-less (ASAP targets, predicted-latch
/// budget).
pub(super) fn start(panel_hz: i32, on_tick: Box<dyn Fn() + Send>) -> Option<VsyncClock> {
let api = ChoreoApi::resolve()?;
let timelines_live = api.post_vsync.is_some();
let shared = Arc::new(VsyncShared {
stop: AtomicBool::new(false),
last_vsync_ns: AtomicI64::new(0),
period_ns: AtomicI64::new(0),
panel_period_ns: AtomicI64::new(if panel_hz > 0 {
1_000_000_000 / panel_hz as i64
} else {
0
}),
ticks: std::sync::atomic::AtomicU32::new(0),
timelines: Mutex::new(Vec::new()),
});
let thread_shared = shared.clone();
let join = std::thread::Builder::new()
.name("pf-vsync".into())
.spawn(move || {
let looper = ndk::looper::ThreadLooper::prepare();
// SAFETY: getInstance on a thread with a prepared looper returns this thread's
// choreographer (never null once a looper exists).
let choreographer = unsafe { (api.get_instance)() };
if choreographer.is_null() {
log::warn!("vsync: AChoreographer_getInstance returned null — no clock");
return;
}
let ctx = CallbackCtx {
api,
choreographer,
shared: thread_shared,
on_tick,
};
ctx.repost();
// The bounded poll doubles as the stop check: no cross-thread wake needed, worst
// case teardown waits one timeout out. Callbacks fire inside poll_once_timeout.
while !ctx.shared.stop.load(Ordering::Relaxed) {
let _ = looper.poll_once_timeout(Duration::from_millis(250));
}
// `ctx` drops here — after the loop, so no queued callback can outlive it (they
// only ever fire inside this thread's poll).
})
.ok()?;
log::info!(
"vsync: choreographer clock started ({})",
if timelines_live {
"frame timelines"
} else {
"frame callback fallback"
}
);
Some(VsyncClock {
shared,
join: Some(join),
})
}
pub(super) fn shared(&self) -> &Arc<VsyncShared> {
&self.shared
}
}
impl Drop for VsyncClock {
fn drop(&mut self) {
self.shared.stop.store(true, Ordering::Relaxed);
if let Some(j) = self.join.take() {
let _ = j.join();
}
}
}
+22 -5
View File
@@ -115,6 +115,8 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
compositor_pref: jint,
gamepad_pref: jint,
hdr_enabled: jboolean,
multi_slice_ok: jboolean,
frame_parts_ok: jboolean,
audio_channels: jint,
video_codecs: jint,
preferred_codec: jint,
@@ -182,11 +184,19 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// sends a proper 8-bit BT.709 stream rather than PQ the panel would mis-tone-map. AMediaCodec
// decodes Main10 from the SPS and the decode loop signals the Surface HDR dataspace + static
// metadata (see crate::decode).
if hdr_enabled != 0 {
// 10-bit/HDR by panel truth (above) + multi-slice by DECODER truth: Kotlin probes every
// decoder this device would use (`VideoDecoders.multiSliceTolerant` — Amlogic wedges the
// whole device on multi-slice AUs, the 0.17.0 field regression) and only then may the
// host default to >1 slice per frame (its sub-frame readback / the P2 slice pipeline).
(if hdr_enabled != 0 {
punktfunk_core::quic::VIDEO_CAP_10BIT | punktfunk_core::quic::VIDEO_CAP_HDR
} else {
0
},
}) | (if multi_slice_ok != 0 {
punktfunk_core::quic::VIDEO_CAP_MULTI_SLICE
} else {
0
}),
// Requested surround layout (2 = stereo / 6 = 5.1 / 8 = 7.1). The host clamps to what it can
// capture and echoes the resolved count in `connector.audio_channels`, which drives the
// decoder + AAudio layout (read in `crate::audio::AudioPlayback::start`). Anything else
@@ -213,9 +223,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
// No display-volume forwarding from Android yet (the panel tone-maps PQ itself via the
// Surface dataspace + static metadata) — the host keeps its virtual-display EDID defaults.
None,
// No non-video caps: this client does not render the host cursor locally (no shape/state
// planes in the jni surface), so advertising CLIENT_CAP_CURSOR would stream cursor-less.
0,
// No CLIENT_CAP_CURSOR: this client does not render the host cursor locally (no
// shape/state planes in the jni surface) advertising it would stream cursor-less.
// CLIENT_CAP_PHASE_LOCK is honest: the async decode loop's presenter feeds
// report_phase (advisory in v1 — the host arms on report receipt — but the Hello
// should say what the client does).
punktfunk_core::quic::CLIENT_CAP_PHASE_LOCK,
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use): AU prefixes then arrive as `Frame::part`
// pieces and the decode loop feeds them with BUFFER_FLAG_PARTIAL_FRAME.
frame_parts_ok != 0,
launch, // a store-qualified library id to boot into a game, or None for the desktop
device_name, // Kotlin's Build.MODEL — the host's approval-list / trust-store label
pin, // Some → Crypto on host-fp mismatch
+113 -42
View File
@@ -185,7 +185,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeHostSupport
/// Floats per sample in the `nativeSendPen` flat array.
const PEN_JNI_STRIDE: usize = 10;
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus batch of STATE-FULL
/// Sample ceiling per `nativeSendPen` call: over-cap runs are SPLIT into consecutive ≤8-sample
/// `send_pen` batches (the send_pen contract — never truncated), so this only bounds the stack
/// buffer. 64 samples ≈ >250 ms of 240 Hz history = a pathological UI-thread stall.
const PEN_JNI_MAX_SAMPLES: usize = PEN_BATCH_MAX * 8;
/// `NativeBridge.nativeSendPen(handle, samples, count)` — one stylus emit of STATE-FULL
/// samples, `count` × [`PEN_JNI_STRIDE`] floats, oldest first:
/// `[state, tool, x, y, pressure, distance, tilt_deg, azimuth_deg, roll_deg, dt_us]`.
/// `state` = the wire `PEN_*` bits; `tool` 0=pen 1=eraser; `x`/`y`/`pressure`/`distance`
@@ -203,53 +208,56 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPen(
if handle == 0 || count <= 0 {
return;
}
let count = (count as usize).min(PEN_BATCH_MAX);
let mut buf = [0f32; PEN_BATCH_MAX * PEN_JNI_STRIDE];
let count = (count as usize).min(PEN_JNI_MAX_SAMPLES);
let mut buf = [0f32; PEN_JNI_MAX_SAMPLES * PEN_JNI_STRIDE];
let flat = &mut buf[..count * PEN_JNI_STRIDE];
if env.get_float_array_region(&samples, 0, flat).is_err() {
return; // short array — a bridge bug, never worth a crash on the input path
}
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
for (slot, s) in batch.iter_mut().zip(flat.chunks_exact(PEN_JNI_STRIDE)) {
if !s[2].is_finite() || !s[3].is_finite() {
return; // never forward a NaN coordinate
}
*slot = PenSample {
state: s[0] as u8,
tool: if s[1] as u8 == 1 {
PenTool::Eraser
} else {
PenTool::Pen
},
x: s[2].clamp(0.0, 1.0),
y: s[3].clamp(0.0, 1.0),
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
distance: if s[5] < 0.0 {
PEN_DISTANCE_UNKNOWN
} else {
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
},
tilt_deg: if s[6] < 0.0 {
PEN_TILT_UNKNOWN
} else {
(s[6].clamp(0.0, 90.0)) as u8
},
azimuth_deg: if s[7] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[7] as u16) % 360
},
roll_deg: if s[8] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[8] as u16) % 360
},
dt_us: s[9].clamp(0.0, 65535.0) as u16,
};
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_pen is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_pen(&batch[..count]);
let mut batch = [PenSample::default(); PEN_BATCH_MAX];
for run in flat.chunks(PEN_BATCH_MAX * PEN_JNI_STRIDE) {
let n = run.len() / PEN_JNI_STRIDE;
for (slot, s) in batch.iter_mut().zip(run.chunks_exact(PEN_JNI_STRIDE)) {
if !s[2].is_finite() || !s[3].is_finite() {
return; // never forward a NaN coordinate
}
*slot = PenSample {
state: s[0] as u8,
tool: if s[1] as u8 == 1 {
PenTool::Eraser
} else {
PenTool::Pen
},
x: s[2].clamp(0.0, 1.0),
y: s[3].clamp(0.0, 1.0),
pressure: (s[4].clamp(0.0, 1.0) * 65535.0) as u16,
distance: if s[5] < 0.0 {
PEN_DISTANCE_UNKNOWN
} else {
(s[5].clamp(0.0, 1.0) * 65534.0) as u16
},
tilt_deg: if s[6] < 0.0 {
PEN_TILT_UNKNOWN
} else {
(s[6].clamp(0.0, 90.0)) as u8
},
azimuth_deg: if s[7] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[7] as u16) % 360
},
roll_deg: if s[8] < 0.0 {
PEN_ANGLE_UNKNOWN
} else {
(s[8] as u16) % 360
},
dt_us: s[9].clamp(0.0, 65535.0) as u16,
};
}
let _ = h.client.send_pen(&batch[..n]);
}
}
/// `NativeBridge.nativeSendText(handle, text)` — committed IME text, one `TextInput` event per
@@ -406,3 +414,66 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadHidR
data,
});
}
/// `NativeBridge.nativeSendPadTouch(handle, pad, finger, active, x, y)` — one touchpad contact
/// from a client-captured controller (the Sony USB capture), forwarded on the rich-input plane
/// (`RichInput::Touchpad`, 0xCC). `finger`: contact slot 0/1; `x`/`y`: normalized 0..=65535 in
/// SCREEN convention (+y down — the wire's fixed meaning); `active` 0 lifts the finger. The
/// host's DualSense-family backends scale onto the virtual pad's touch surface. On-change only —
/// the capture diffs, the host holds per-slot state.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadTouch(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
finger: jint,
active: jboolean,
x: jint,
y: jint,
) {
if handle == 0 {
return;
}
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::Touchpad {
pad: (pad as u32 & 0xF) as u8,
finger: (finger as u32 & 0x1) as u8,
active: active != 0,
x: (x as i64).clamp(0, 65535) as u16,
y: (y as i64).clamp(0, 65535) as u16,
});
}
/// `NativeBridge.nativeSendPadMotion(handle, pad, gp, gy, gr, ax, ay, az)` — one motion sample
/// from a client-captured controller (`RichInput::Motion`, 0xCC): gyro pitch/yaw/roll + accel,
/// raw signed-16 values in the pad's own units, passed straight into the host's virtual
/// DualSense report (the wire is a unit passthrough). Called from the capture thread at the
/// controller's report rate.
#[no_mangle]
#[allow(clippy::too_many_arguments)]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeSendPadMotion(
_env: JNIEnv,
_this: JObject,
handle: jlong,
pad: jint,
gyro_pitch: jint,
gyro_yaw: jint,
gyro_roll: jint,
accel_x: jint,
accel_y: jint,
accel_z: jint,
) {
if handle == 0 {
return;
}
let c = |v: jint| (v as i64).clamp(i64::from(i16::MIN), i64::from(i16::MAX)) as i16;
// SAFETY: live handle per the nativeConnect/nativeClose contract; send_rich_input is &self.
let h = unsafe { &*(handle as *const SessionHandle) };
let _ = h.client.send_rich_input(RichInput::Motion {
pad: (pad as u32 & 0xF) as u8,
gyro: [c(gyro_pitch), c(gyro_yaw), c(gyro_roll)],
accel: [c(accel_x), c(accel_y), c(accel_z)],
});
}
+38 -13
View File
@@ -10,11 +10,13 @@ use jni::JNIEnv;
use super::{jni_guard, SessionHandle};
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature)`
/// — 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).
/// `NativeBridge.nativeStartVideo(handle, surface, decoderName, lowLatencyMode, lowLatencyFeature,
/// isTv, presentPriority, smoothBuffer)` — 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); `presentPriority`/`smoothBuffer` are the timeline
/// presenter's intent (0 = lowest latency / 1 = smoothness; buffer 0 = auto, 1..=3 frames).
/// No-op if already started.
#[cfg(target_os = "android")]
#[no_mangle]
@@ -27,6 +29,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
low_latency_mode: jboolean,
ll_feature: jboolean,
is_tv: jboolean,
present_priority: jni::sys::jint,
smooth_buffer: jni::sys::jint,
panel_fps: jni::sys::jint,
) {
use super::VideoThread;
use std::sync::atomic::AtomicBool;
@@ -70,6 +75,9 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStartVideo(
ll_feature: ll_feature != 0,
low_latency_mode: low_latency_mode != 0,
is_tv: is_tv != 0,
present_priority,
smooth_buffer,
panel_hz: panel_fps,
};
let join = std::thread::Builder::new()
.name("pf-decode".into())
@@ -173,7 +181,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
/// `[fps, mbps, e2eP50Ms, e2eP95Ms, latValid, skewCorrected, width, height, refreshHz, framesLost,
/// bitDepth, colorPrimaries, colorTransfer, chromaFormatIdc, hostNetP50Ms, decodeP50Ms, hostP50Ms,
/// netP50Ms, lostWindow, skippedWindow, fecWindow, framesWindow, dispValid, displayP50Ms,
/// e2eDispP50Ms, e2eDispP95Ms]`
/// e2eDispP50Ms, e2eDispP95Ms, paceP50Ms, latchP50Ms, presentsWindow, presenterActive]`
/// (the flags are 1.0/0.0; indexes 021 match the previous 22-double layout — 013 the original
/// 14-double one with the latency pair re-based to the end-to-end capture→decoded headline, 14/15
/// the stage p50s tiling it: `host+network` = capture→received, `decode` = received→decoded; 16/17
@@ -186,7 +194,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeStopVideo(
/// OnFrameRendered render timestamps — when `dispValid` is 1.0 the HUD headline becomes the
/// directly-measured capture→displayed pair at 24/25 with `display` = decoded→displayed p50 at 23
/// closing the equation, and when 0.0 — no render callback landed this window — it falls back to
/// the capture→decoded headline at 2/3), or `null` when no decode thread is running.
/// the capture→decoded headline at 2/3; 2629 are the timeline presenter's split of the `display`
/// term — `pace` = decoded→release (store + glass budget) p50 at 26, `latch` =
/// release→displayed (SurfaceFlinger) p50 at 27, the window's on-glass confirm count at 28
/// (`presents` vs `fps` is the presenter-health pair), and 29 = 1.0 while the timeline presenter
/// is active this session), or `null` when no decode thread is running.
/// Poll ~1 Hz from the UI; each call
/// resets the measurement window. Not android-gated — pure `jni` + connector reads, so it links on
/// the host build too (Kotlin only ever calls it on device).
@@ -210,7 +222,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
.drain(h.client.frames_dropped(), h.client.fec_recovered_shards());
let mode = h.client.mode();
let color = h.client.color;
let buf: [f64; 26] = [
let buf: [f64; 30] = [
snap.fps,
snap.mbps,
snap.e2e_p50_ms,
@@ -251,6 +263,13 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
snap.display_p50_ms,
snap.e2e_disp_p50_ms,
snap.e2e_disp_p95_ms,
// Timeline-presenter split of the `display` term (pace = decoded→release, latch =
// release→displayed), the window's on-glass confirm count, and whether the presenter
// is active at all (0.0 = legacy release-immediately path — split reads 0 too).
snap.pace_p50_ms,
snap.latch_p50_ms,
snap.presents as f64,
if h.stats.presenter_active() { 1.0 } else { 0.0 },
];
let arr = match env.new_double_array(buf.len() as jsize) {
Ok(a) => a,
@@ -264,10 +283,12 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoStats(
}
/// `NativeBridge.nativeVideoSize(handle): IntArray?` — the negotiated video mode as
/// `[width, height]`. Resolved at the handshake (Welcome), so it is known before a single frame
/// arrives: the UI sizes the video surface to the STREAM's aspect rather than stretching it to the
/// panel's. `null` on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links
/// on the host build too. Cheap; safe on the UI thread.
/// `[width, height, refreshHz]`. Resolved at the handshake (Welcome), so it is known before a
/// single frame arrives: the UI sizes the video surface to the STREAM's aspect rather than
/// stretching it to the panel's, and pins the panel's display mode to the stream refresh. The
/// trailing `refreshHz` was appended later — old readers index only 0/1 and never see it. `null`
/// on a `0` handle. Not android-gated — pure `jni` + a connector read, so it links on the host
/// build too. Cheap; safe on the UI thread.
#[no_mangle]
pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
env: JNIEnv,
@@ -281,7 +302,11 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeVideoSize(
// SAFETY: live handle per the nativeConnect/nativeClose contract.
let h = unsafe { &*(handle as *const SessionHandle) };
let mode = h.client.mode();
let buf: [i32; 2] = [mode.width as i32, mode.height as i32];
let buf: [i32; 3] = [
mode.width as i32,
mode.height as i32,
mode.refresh_hz as i32,
];
let arr = match env.new_int_array(buf.len() as jsize) {
Ok(a) => a,
Err(_) => return std::ptr::null_mut(),
+78 -5
View File
@@ -28,6 +28,9 @@ pub struct VideoStats {
/// they (and the caller's latency computation — see `enabled`) early-out on this flag alone.
/// Off until Kotlin shows the HUD.
enabled: AtomicBool,
/// Whether the timeline presenter is active this session (async loop, not sysprop-disabled) —
/// stats index 29, so the HUD can label the display split it is (or isn't) getting.
presenter_active: 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`.
@@ -67,6 +70,16 @@ struct Inner {
/// `end-to-end` = capture→displayed samples, µs (skew-corrected) — the spec's headline,
/// measured directly (not summed from stages). Empty under the same fallback as `display_us`.
e2e_disp_us: Vec<u64>,
/// The `display` stage's presenter split, decoded→release (pace wait: store + glass budget),
/// µs. Empty on the legacy release-immediately path.
pace_us: Vec<u64>,
/// The other half of the split, release→displayed (SurfaceFlinger's latch + scanout), µs —
/// from the `OnFrameRendered` render timestamps. `pace + latch ≈ display` per frame.
latch_us: Vec<u64>,
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks) — the `presents`-vs-
/// `fps` health pair: presents ≪ fps means the presenter is dropping/serializing; an fps
/// deficit is upstream.
presents: u64,
/// Client-side newest-wins/pacing drops this window (decoded frames released without
/// rendering, or parked AUs dropped on overflow) — the spec's `skipped` counter.
skipped: u64,
@@ -101,6 +114,13 @@ pub struct Snapshot {
/// Whether any capture→displayed sample landed this window — gates the HUD's headline endpoint
/// (`capture→displayed` vs the capture→decoded fallback) and the equation's `display` term.
pub disp_valid: bool,
/// The `display` stage's presenter split p50s (ms): `pace` = decoded→release (store + glass
/// budget), `latch` = release→displayed (SurfaceFlinger). 0.0 when no sample landed (legacy
/// path / no render callbacks).
pub pace_p50_ms: f64,
pub latch_p50_ms: f64,
/// Frames confirmed on glass this window (`OnFrameRendered` callbacks).
pub presents: u64,
/// Phase-2 `host` / `network` split p50s (ms) — 0.0 when no 0xCF timing matched this window
/// (old host / no samples yet), in which case the HUD keeps the combined `host+network` term.
pub host_p50_ms: f64,
@@ -132,6 +152,7 @@ impl VideoStats {
pub fn new() -> VideoStats {
VideoStats {
enabled: AtomicBool::new(false),
presenter_active: AtomicBool::new(false),
decoder: Mutex::new(None),
inner: Mutex::new(Inner {
window_start: Instant::now(),
@@ -144,6 +165,9 @@ impl VideoStats {
decode_us: Vec::with_capacity(256),
display_us: Vec::with_capacity(256),
e2e_disp_us: Vec::with_capacity(256),
pace_us: Vec::with_capacity(256),
latch_us: Vec::with_capacity(256),
presents: 0,
skipped: 0,
last_dropped_total: 0,
last_fec_total: 0,
@@ -160,6 +184,18 @@ impl VideoStats {
self.enabled.load(Ordering::Relaxed)
}
/// Record whether the timeline presenter runs this session (decode thread, once at start).
// 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_presenter_active(&self, on: bool) {
self.presenter_active.store(on, Ordering::Relaxed);
}
/// Whether the timeline presenter is active (stats index 29).
pub fn presenter_active(&self) -> bool {
self.presenter_active.load(Ordering::Relaxed)
}
/// Toggle sampling. Enabling resets the window, so the first HUD poll after a show never mixes
/// in counters (or a window start) from before the overlay was visible. `dropped_total` /
/// `fec_total` are the connector's session-cumulative counters at this instant — they seed the
@@ -181,6 +217,9 @@ impl VideoStats {
g.decode_us.clear();
g.display_us.clear();
g.e2e_disp_us.clear();
g.pace_us.clear();
g.latch_us.clear();
g.presents = 0;
g.skipped = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
@@ -298,13 +337,19 @@ impl VideoStats {
}
/// Record one displayed frame (the `OnFrameRendered` render timestamp, re-based to the
/// realtime clock): its capture→displayed `end-to-end` sample and its decoded→displayed
/// `display` stage sample (either may be absent — the e2e clamp rejected an out-of-range
/// value, or the decoded stamp for this pts was already evicted/pre-HUD). Fired from the
/// codec's render-callback thread, not the decode thread — the lock makes that safe.
/// realtime clock): its capture→displayed `end-to-end` sample, its decoded→displayed
/// `display` stage sample, and the presenter split's `latch` half (release→displayed) — any
/// may be absent (the e2e clamp rejected an out-of-range value, or the release record for
/// this pts was already evicted). Fired from the codec's render-callback thread, not the
/// decode thread — the lock makes that safe.
// Driven only by the android-only decode path; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_displayed(&self, e2e_us: Option<u64>, display_us: Option<u64>) {
pub fn note_displayed(
&self,
e2e_us: Option<u64>,
display_us: Option<u64>,
latch_us: Option<u64>,
) {
if !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock (the callback already skipped the clock reads)
}
@@ -313,12 +358,32 @@ impl VideoStats {
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.presents += 1;
if let Some(l) = e2e_us {
g.e2e_disp_us.push(l);
}
if let Some(l) = display_us {
g.display_us.push(l);
}
if let Some(l) = latch_us {
g.latch_us.push(l);
}
}
/// Record one released frame's pace-wait (decoded→release: the presenter's store + glass
/// budget), µs — the `display` stage's other half. Decode-thread only.
// Driven only by the android-only decode thread; unreferenced on the host build — expected.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub fn note_release(&self, pace_us: u64) {
if !self.enabled.load(Ordering::Relaxed) {
return; // HUD hidden — skip the lock
}
// Poison-proof for the same reason as `note_received`.
let mut g = self
.inner
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
g.pace_us.push(pace_us);
}
/// Compute the window's rates + latency percentiles, then reset for the next window.
@@ -341,6 +406,8 @@ impl VideoStats {
g.decode_us.sort_unstable();
g.display_us.sort_unstable();
g.e2e_disp_us.sort_unstable();
g.pace_us.sort_unstable();
g.latch_us.sort_unstable();
let snap = Snapshot {
fps,
mbps,
@@ -352,6 +419,9 @@ impl VideoStats {
decode_p50_ms: pctl_ms(&g.decode_us, 0.50),
display_p50_ms: pctl_ms(&g.display_us, 0.50),
disp_valid: !g.e2e_disp_us.is_empty(),
pace_p50_ms: pctl_ms(&g.pace_us, 0.50),
latch_p50_ms: pctl_ms(&g.latch_us, 0.50),
presents: g.presents,
host_p50_ms: pctl_ms(&g.host_us, 0.50),
net_p50_ms: pctl_ms(&g.net_us, 0.50),
lat_valid: !g.e2e_us.is_empty(),
@@ -371,6 +441,9 @@ impl VideoStats {
g.decode_us.clear();
g.display_us.clear();
g.e2e_disp_us.clear();
g.pace_us.clear();
g.latch_us.clear();
g.presents = 0;
g.skipped = 0;
g.last_dropped_total = dropped_total;
g.last_fec_total = fec_total;
+4 -3
View File
@@ -34,9 +34,10 @@ let package = Package(
// Geist (SIL OFL 1.1) the brand typeface, shared with punktfunk-website.
// Registered with Core Text at first use; see BrandFont.swift.
.copy("Resources/Fonts"),
// The host cards' OS marks (template vector imagesets derived from the
// assets/os-icons masters FA brands CC BY 4.0 + Simple Icons CC0, see that
// README). `.process` compiles the catalog; loaded via OsIcon.swift.
// The host cards' OS marks (template vector imagesets generated from the
// assets/os-icons masters by scripts/gen-os-icons.sh per-mark provenance and
// licensing in that README). `.process` compiles the catalog; loaded via
// OsIcon.swift.
.process("Resources/OsIcons.xcassets"),
],
linkerSettings: [
@@ -338,7 +338,10 @@ final class SessionModel: ObservableObject {
let clientCaps: UInt8 =
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
#else
let clientCaps: UInt8 = 0
// iOS/tvOS run the stage-4 deadline presenter, whose link thread feeds
// reportPhase advertise the vsync-aware presenter (0x02, CLIENT_CAP_PHASE_LOCK).
// macOS stays without it: the stage-2 arrival presenter has no latch grid.
let clientCaps: UInt8 = 0x02
#endif
let result = Result { try PunktfunkConnection(
host: host.address, port: host.port,
@@ -761,6 +761,21 @@ public final class PunktfunkConnection {
return out
}
/// Report the display-latch grid + circular arrival-phase statistic so the host can
/// phase-lock its capture tick (design/phase-locked-capture.md). Fire-and-forget; call
/// ~1 Hz from a vsync-aware presenter. `nextLatchHostNs` must already be HOST clock
/// convert with `clockOffsetNs` (host client). No-op toward a host that never armed.
public func reportPhase(
nextLatchHostNs: UInt64, latchPeriodNs: UInt32, uncertaintyNs: UInt32,
arrivalLeadNs: UInt32, coherenceMilli: UInt16
) {
abiLock.lock()
defer { abiLock.unlock() }
guard let h = handle, !closeRequested else { return }
_ = punktfunk_connection_report_phase(
h, nextLatchHostNs, latchPeriodNs, uncertaintyNs, arrivalLeadNs, coherenceMilli)
}
/// The currently active session mode (updated by accepted `requestMode` switches).
public func currentMode() -> (width: UInt32, height: UInt32, refreshHz: UInt32) {
abiLock.lock()
@@ -1,17 +1,19 @@
// The host cards' OS marks: template vector imagesets in Resources/OsIcons.xcassets
// (derived from the repo's assets/os-icons masters Font Awesome Free brands CC BY 4.0 +
// Simple Icons CC0; provenance in that directory's README), resolved from the host's
// (generated from the repo's assets/os-icons masters by scripts/gen-os-icons.sh
// per-mark provenance and licensing in that directory's README), resolved from the host's
// OS-identity chain via PunktfunkShared's `osIconTokens` walk. Template rendering means
// they tint with `foregroundStyle` like an SF Symbol.
import PunktfunkShared
import SwiftUI
/// The icon tokens this client ships art for. A distro without its own mark (Bazzite,
/// CachyOS, ...) degrades to its family's and finally to Tux via the chain walk.
/// The icon tokens this client ships art for: the families a chain can land on, plus the
/// gaming distros that earn their own mark because "a Bazzite box" and "a Fedora box" are
/// different machines to the person reading the card. A distro with no mark of its own
/// still degrades to its family's and finally to Tux via the chain walk.
private let osIconTokensShipped: Set<String> = [
"windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos",
"opensuse",
"opensuse", "bazzite", "cachyos", "nobara",
]
/// The mark for an OS-identity chain (`linux/fedora/bazzite`, ...), or nil no view at
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "bazzite.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "cachyos.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
@@ -0,0 +1,10 @@
{
"images" : [
{ "filename" : "nobara.pdf", "idiom" : "universal" }
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}
File diff suppressed because it is too large Load Diff
@@ -355,6 +355,13 @@ final class SessionPresenter {
/// behavior iOS keeps a 30 Hz floor; macOS leaves the NSView link at its display's native
/// rate (it already tracks the display and must NOT be capped to the stream rate).
/// Re-applied from `layout` so a mid-session `Reconfigure` picks up a new refresh.
/// Pen-proximity panel-rate boost pass-through (Stage2Pipeline.setInteractionBoost):
/// deadline pacing only under arrival/glass the staged hint feeds no link, so this
/// is a no-op there. MAIN thread.
func setInteractionBoost(_ on: Bool) {
stage2?.setInteractionBoost(on)
}
private func syncFrameRate(hz: UInt32) {
guard hz > 0 else { return }
// Deadline pacing: the hint goes to the pipeline's CAMetalDisplayLink instead (staged;
@@ -215,7 +215,8 @@ private final class VsyncClock: @unchecked Sendable {
/// ~23 refreshes of queue (the measured 2330 ms display stage on 120 Hz ProMotion panels), and
/// the full-queue regime is where hostpanel clock drift turns into periodic repeats/drops (the
/// "fixed-interval" jitter reports).
/// - `glass` (stage-3, the tvOS default): at most a small BOUNDED number of presented-but-
/// - `glass` (stage-3; tvOS's default until the 2026-07 rebuild moved it to stage-4, now an
/// on-device A/B rung): at most a small BOUNDED number of presented-but-
/// undisplayed drawables in flight (`PresentGate`; depth 1 see `SessionPresenter.gateDepth`
/// for why deeper is a regression). The render thread presents only while a gate slot is free
/// (a drawable's presented handler reopens its slot and re-signals); frames decoded meanwhile
@@ -278,10 +279,27 @@ final class LatestBox<T>: @unchecked Sendable {
private final class FrameRateHint: @unchecked Sendable {
private let lock = NSLock()
private var pending: CAFrameRateRange?
private var streamHz: Float = 0
private var boosted = false
func stage(hz: Float) {
guard hz > 0 else { return }
lock.lock()
pending = CAFrameRateRange(minimum: hz, maximum: max(hz, 120), preferred: hz)
streamHz = hz
pending = Self.range(hz: hz, boosted: boosted)
lock.unlock()
}
/// Pen-proximity boost: pin `minimum = preferred` at the range's CEILING instead of the
/// stream rate. UIKit delivers touch/Pencil events at the PANEL's cadence, and the panel
/// follows this link's vote so a 60 fps stream on a 120 Hz iPad halves pencil sampling
/// unless a boost lifts the panel while the Pencil is in range. Presents still pace at
/// stream rate (extra link updates just vend into the newest-wins stash), so the cost is
/// empty wakes, scoped to pen proximity.
func setBoost(_ on: Bool) {
lock.lock()
if boosted != on {
boosted = on
if streamHz > 0 { pending = Self.range(hz: streamHz, boosted: on) }
}
lock.unlock()
}
func drain() -> CAFrameRateRange? {
@@ -291,6 +309,113 @@ private final class FrameRateHint: @unchecked Sendable {
pending = nil
return p
}
private static func range(hz: Float, boosted: Bool) -> CAFrameRateRange {
let cap = max(hz, 120)
let preferred = boosted ? cap : hz
return CAFrameRateRange(minimum: preferred, maximum: cap, preferred: preferred)
}
}
/// The client half of phase-locked capture (design/phase-locked-capture.md): the decode
/// callback deposits per-AU arrival stamps (client CLOCK_REALTIME the core's reassembly-
/// completion time), the deadline link's thread deposits the latch grid, and ~1 Hz that same
/// thread flushes the circular arrival-phase statistic to the host. The statistic is a
/// verbatim port of `punktfunk_core::phase::circular_latch` the host's v3 controller
/// (grid-locked submits, coherence-gated engage) was tuned against exactly it, and a
/// period-smeared Wi-Fi link correctly reads coherence 0 there, so the controller never
/// engages where alignment is physically pointless. Shared box (never captures the pipeline);
/// a session's connection binds/unbinds like DecodeReport/KeyframeRecovery.
final class PhaseReporter: @unchecked Sendable {
private let lock = NSLock()
private var connection: PunktfunkConnection?
/// Arrival stamps since the last flush, client CLOCK_REALTIME. Bounded: ~1 s at 240 fps.
private var arrivalsNs: [Int64] = []
/// Smallest update-to-update spacing this window: successive `nextLatch` values sit one
/// panel period apart except across skipped link updates (2×, 3×, ), so the window
/// minimum IS the period. Re-learned every flush so VRR/mode switches track both ways.
private var periodNs: Int64 = 0
private var prevLatchRealNs: Int64 = 0
private var lastFlushRealNs: Int64 = 0
func bind(_ c: PunktfunkConnection?) {
lock.lock()
connection = c
arrivalsNs.removeAll()
periodNs = 0
prevLatchRealNs = 0
lastFlushRealNs = 0
lock.unlock()
}
/// Decode-callback side: one AU's arrival (reassembly-completion) stamp.
func noteArrival(receivedNs: Int64) {
lock.lock()
if connection != nil, arrivalsNs.count < 256 { arrivalsNs.append(receivedNs) }
lock.unlock()
}
/// Link-thread side, once per update: where the NEXT latch sits on the client's realtime
/// clock (the arrival stamps' domain). Learns the period from update spacing and ~1 Hz
/// converts the window's arrivals into leads against this grid, then reports a
/// fire-and-forget datagram push on the control plane.
func noteGrid(nextLatchRealNs: Int64) {
lock.lock()
if prevLatchRealNs > 0 {
let delta = nextLatchRealNs - prevLatchRealNs
// 2100 ms accepts 10500 Hz panels, rejects wakeup hiccups and clock jumps.
if delta > 2_000_000, delta < 100_000_000, periodNs == 0 || delta < periodNs {
periodNs = delta
}
}
prevLatchRealNs = nextLatchRealNs
guard let c = connection, periodNs > 0, arrivalsNs.count >= 8,
nextLatchRealNs - lastFlushRealNs >= 1_000_000_000
else {
lock.unlock()
return
}
lastFlushRealNs = nextLatchRealNs
let period = periodNs
let leadsUs = arrivalsNs.map { a -> UInt64 in
let m = (nextLatchRealNs - a) % period
return UInt64(m < 0 ? m + period : m) / 1000
}
arrivalsNs.removeAll(keepingCapacity: true)
periodNs = 0
let offsetNs = c.clockOffsetNs
lock.unlock()
guard
let (leadMeanNs, coherence) = Self.circularLatch(
samplesUs: leadsUs, periodNs: period)
else { return }
c.reportPhase(
nextLatchHostNs: UInt64(max(0, nextLatchRealNs + offsetNs)),
latchPeriodNs: UInt32(clamping: period),
uncertaintyNs: 1_000_000, // skew residual same conservative 1 ms as Android
arrivalLeadNs: UInt32(clamping: leadMeanNs),
coherenceMilli: coherence)
}
/// Verbatim port of `punktfunk_core::phase::circular_latch` (µs samples against an ns
/// period; nil under 8 samples). The MEAN is what a phase controller can steer under
/// jitter a period-spanning distribution's median is immovable and the coherence
/// (resultant length, ) says whether any phase exists to steer at all.
static func circularLatch(samplesUs: [UInt64], periodNs: Int64) -> (UInt64, UInt16)? {
guard samplesUs.count >= 8, periodNs > 0 else { return nil }
let periodUs = Double(periodNs) / 1000.0
var x = 0.0
var y = 0.0
for s in samplesUs {
let theta = Double(s).truncatingRemainder(dividingBy: periodUs) / periodUs * 2 * .pi
x += cos(theta)
y += sin(theta)
}
let n = Double(samplesUs.count)
let r = (x * x + y * y).squareRoot() / n
var meanTheta = atan2(y, x)
if meanTheta < 0 { meanTheta += 2 * .pi }
return (UInt64(meanTheta / (2 * .pi) * Double(periodNs)), UInt16(r * 1000.0))
}
}
/// The CAMetalDisplayLink delegate for deadline pacing: each per-refresh update stashes its
@@ -304,6 +429,8 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
private let renderSignal: DispatchSemaphore
private let hint: FrameRateHint
private let stats: PresentDebugStats?
/// Phase-locked capture's grid feed this link IS the latch grid presents pace against.
private let phase: PhaseReporter?
/// The OS-floor sampler (design/apple-presentation-rebuild.md): every update's vendglass
/// lead is recorded so its p50 becomes the "OS present floor" the HUD subtracts from the
/// shown display/e2e numbers. Self-adapting reads ~2 refresh periods composited today,
@@ -316,13 +443,15 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
init(
stash: LatestBox<CAMetalDrawable>, renderSignal: DispatchSemaphore,
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?
hint: FrameRateHint, stats: PresentDebugStats?, floorMeter: LatencyMeter?,
phase: PhaseReporter?
) {
self.stash = stash
self.renderSignal = renderSignal
self.hint = hint
self.stats = stats
self.floorMeter = floorMeter
self.phase = phase
}
func metalDisplayLink(_ link: CAMetalDisplayLink, needsUpdate update: CAMetalDisplayLink.Update) {
@@ -354,6 +483,15 @@ private final class DeadlineLinkDelegate: NSObject, CAMetalDisplayLinkDelegate {
floorMeter.record(
ptsNs: UInt64(nowNs - Int64(leadS * 1_000_000_000)), atNs: nowNs, offsetNs: 0)
}
// Phase-locked capture: this update's target present, converted into the arrival
// stamps' CLOCK_REALTIME domain. Per-update cost is one clock read; the reporter
// itself flushes ~1 Hz.
if let phase {
var ts = timespec()
clock_gettime(CLOCK_REALTIME, &ts)
let nowNs = Int64(ts.tv_sec) * 1_000_000_000 + Int64(ts.tv_nsec)
phase.noteGrid(nextLatchRealNs: nowNs + Int64(leadS * 1_000_000_000))
}
stash.put(update.drawable)
renderSignal.signal()
}
@@ -583,6 +721,7 @@ public final class Stage2Pipeline {
/// Feeds the core Automatic-bitrate controller's decode signal from the decode callback; `start`
/// binds the live connection + arming flag (see DecodeReport).
private let decodeReport = DecodeReport()
private let phaseReporter = PhaseReporter()
/// Post-loss freeze-until-reanchor gate (shared core policy via the C ABI). Created here seeded 0;
/// `start` reseeds it to the live connection's drop count. Captured by the decoder callbacks
/// (which withhold concealed frames) and driven by the pump (arm on a gap, poll per iteration).
@@ -649,6 +788,7 @@ public final class Stage2Pipeline {
let renderSignal = renderSignal
let gate = gate
let decodeReport = decodeReport
let phaseReporter = phaseReporter
self.decoder = VideoDecoder(
onDecoded: { frame in
// Decode stage = receiveddecoded, both client CLOCK_REALTIME (offset 0 no
@@ -660,6 +800,9 @@ public final class Stage2Pipeline {
// device's real decode limit instead of the network link ceiling. Every decoded
// frame (not just presented ones), so a newest-wins drop can't hide the backlog.
decodeReport.record(receivedNs: frame.receivedNs, decodedNs: frame.decodedNs)
// Phase-locked capture's arrival half: the stamp VALUE is reassembly
// completion, so recording it at decode adds no bias to the 1 Hz aggregate.
phaseReporter.noteArrival(receivedNs: frame.receivedNs)
// Freeze-until-reanchor: WITHHOLD a decoder-concealed post-loss frame (the gray/
// garbage VideoToolbox returns Ok for a reference-missing delta) don't submit it,
// so the CAMetalLayer keeps its last good drawable on glass. The gate lifts (returns
@@ -689,6 +832,7 @@ public final class Stage2Pipeline {
offsetNs = connection.clockOffsetNs
recovery.bind(connection) // arm host-keyframe recovery for this session
decodeReport.bind(connection) // arm the Automatic-bitrate decode signal for this session
phaseReporter.bind(connection) // arm phase reports (flushed only by the deadline link)
gate.reseed(framesDropped: connection.framesDropped()) // baseline the freeze to this session
token = StopFlag() // fresh token per start a stop is permanent (like StreamPump)
@@ -975,6 +1119,7 @@ public final class Stage2Pipeline {
let stash = LatestBox<CAMetalDrawable>()
let floorMeter = presentFloorMeter
let phaseReporter = phaseReporter
// The link starts LAZILY the render thread triggers this after the FIRST decoded
// frame's reconcileLayer. Started eagerly it vends into the layer's initial 0×0
// drawableSize for the whole connect window: every vend fails allocation and the system
@@ -985,7 +1130,7 @@ public final class Stage2Pipeline {
let linkThread = Thread {
let delegate = DeadlineLinkDelegate(
stash: stash, renderSignal: renderSignal, hint: hint, stats: debugStats,
floorMeter: floorMeter)
floorMeter: floorMeter, phase: phaseReporter)
let link = CAMetalDisplayLink(metalLayer: layer)
link.preferredFrameLatency = 1 // wake as late as fits: latch the NEXT refresh
if let range = hint.drain() { link.preferredFrameRateRange = range }
@@ -1107,6 +1252,15 @@ public final class Stage2Pipeline {
frameRateHint.stage(hz: hz)
}
/// Pen-proximity rate boost (drawing workloads): drive the deadline link and with it the
/// panel, whose cadence paces UIKit's touch/Pencil event delivery at the range ceiling
/// while a Pencil is in range, so a sub-panel-rate stream stops halving pencil sampling.
/// Staged like the rate hint; no-op under arrival/glass pacing. MAIN thread.
public func setInteractionBoost(_ on: Bool) {
frameRateHint.setBoost(on)
presentLog.info("pen boost \(on ? "engaged" : "released", privacy: .public)")
}
/// Forward the layout-derived drawable pixel size to the presenter (MAIN thread see
/// `MetalVideoPresenter.setDrawableTarget`).
public func setDrawableTarget(_ size: CGSize) {
@@ -1154,6 +1308,7 @@ public final class Stage2Pipeline {
}
decoder.reset()
recovery.bind(nil) // stop requesting keyframes once the session is torn down
phaseReporter.bind(nil) // and stop phase reports toward the dead connection
}
deinit {
@@ -23,6 +23,10 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
/// One assembled batch ( `PUNKTFUNK_PEN_BATCH_MAX` samples) ready for the connection.
var send: (([PunktfunkPenSample]) -> Void)?
/// Proximity transitions (in-range touching) drives the panel-rate boost while the
/// Pencil is near the glass. Fired from emit(), the choke point every state change exits
/// through.
var onProximity: ((Bool) -> Void)?
/// View-space point normalized [0,1] video coordinates (the letterbox mapping the
/// touch path already uses). nil until a mode is negotiated samples are dropped then.
var videoNorm: ((CGPoint) -> (Float, Float)?)?
@@ -40,6 +44,10 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
private(set) var hoverActive = false
private var last = PencilStream.idleSample()
private var heartbeat: Timer?
/// When the last batch (event or keepalive) went out the heartbeat tick's idle test.
private var lastSendTs: TimeInterval = 0
/// The last proximity state surfaced through `onProximity` (transition detection).
private var lastProximity = false
// MARK: - Contact path (UITouch, `.pencil` only)
@@ -52,11 +60,12 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
touching = true
inRange = true
// Coalesced samples restore the Pencil's full capture rate (UIKit delivers at
// display cadence); oldest first, `dt_us` preserving their spacing.
// display cadence); oldest first, `dt_us` preserving their spacing. The whole run
// is kept emit() splits anything over the wire's batch cap.
let raw = event?.coalescedTouches(for: touch) ?? [touch]
var batch: [PunktfunkPenSample] = []
var prevTs: TimeInterval?
for t in raw.suffix(Int(PUNKTFUNK_PEN_BATCH_MAX)) {
for t in raw {
guard let s = contactSample(t, in: view, prevTs: prevTs) else { continue }
prevTs = t.timestamp
batch.append(s)
@@ -202,27 +211,51 @@ final class PencilStream: NSObject, UIPencilInteractionDelegate {
guard !batch.isEmpty else { return }
last = batch[batch.count - 1]
last.dt_us = 0
send?(batch)
armHeartbeat()
// A run longer than the wire's batch cap is SPLIT into consecutive sends, never
// truncated (the send_pen contract): an over-cap run means the main thread hitched
// past a frame of 240 Hz samples, and dropping its head would notch exactly the
// stroke geometry a drawing app can least afford to lose.
var start = 0
while start < batch.count {
let end = min(start + Int(PUNKTFUNK_PEN_BATCH_MAX), batch.count)
send?(Array(batch[start..<end]))
start = end
}
lastSendTs = CACurrentMediaTime()
syncHeartbeat()
let near = inRange || touching
if near != lastProximity {
lastProximity = near
onProximity?(near)
}
}
/// The 100 ms keepalive while in range (see the file header). 80 ms leaves headroom
/// under the host's 200 ms failsafe even with one lost datagram.
private func armHeartbeat() {
heartbeat?.invalidate()
/// The 100 ms keepalive while in range (see the file header): ONE long-lived 50 ms timer
/// in `.common` run-loop mode, armed on range entry and torn down on exit. `.default`-mode
/// scheduling pauses during run-loop tracking, where a stationary pen would silently cross
/// the host's 200 ms force-release mid-stroke; the old per-emit re-arm also churned the run
/// loop once per event during a stroke. The tick resends only after 50 ms of send silence,
/// so an active stroke costs nothing and the worst-case gap stays 100 ms the wire bound.
private func syncHeartbeat() {
guard inRange || touching else {
heartbeat?.invalidate()
heartbeat = nil
return
}
heartbeat = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) {
[weak self] _ in
guard heartbeat == nil else { return }
let timer = Timer(timeInterval: 0.05, repeats: true) { [weak self] t in
guard let self, self.inRange || self.touching else {
self?.heartbeat?.invalidate()
t.invalidate()
self?.heartbeat = nil
return
}
self.send?([self.last])
if CACurrentMediaTime() - self.lastSendTs >= 0.05 {
self.send?([self.last])
self.lastSendTs = CACurrentMediaTime()
}
}
heartbeat = timer
RunLoop.main.add(timer, forMode: .common)
}
// MARK: - Angle conversions
@@ -154,6 +154,9 @@ public final class StreamViewController: StreamViewControllerBase {
/// The shared presenter stack: stage-2 (CAMetalLayer sublayer + display link) with the
/// stage-1 StreamPump displayLayer path as the Metal-unavailable / DEBUG fallback.
private let presenter = SessionPresenter()
/// Pending pen-boost release 2 s of hysteresis so hover flicker at the glass edge
/// doesn't thrash the deadline link's rate range (see `setInteractionBoost`).
private var penBoostRelease: DispatchWorkItem?
#if os(tvOS)
/// The window's display manager the session's mode request was set on held weakly so
/// stop() can clear the request even after the view has left the window.
@@ -320,12 +323,22 @@ public final class StreamViewController: StreamViewControllerBase {
// prior session (stop() doesn't clear it). Otherwise a stale `true` could later
// re-engage capture on a foreground that the new session never asked for.
wasCapturedOnResign = false
// Read the LIVE mode per touch batch an accepted requestMode() mid-stream
// changes the letterbox, and touches must follow it.
// The letterbox must follow an accepted requestMode() mid-stream, so this stays a live
// read but behind a short TTL: the pencil path maps every coalesced sample through
// here (8 per event at panel rate, main thread), and a per-sample FFI read re-takes
// the ABI lock the batch send itself needs next. 250 ms staleness is invisible next to
// the video reconfigure a mode switch performs anyway. Main-thread-only closure.
var cachedMode = CGSize.zero
var cachedAt = CACurrentMediaTime() - 1
streamView.currentHostMode = { [weak connection] in
guard let connection else { return .zero }
let mode = connection.currentMode()
return CGSize(width: Double(mode.width), height: Double(mode.height))
let now = CACurrentMediaTime()
if now - cachedAt > 0.25 {
guard let connection else { return .zero }
let mode = connection.currentMode()
cachedMode = CGSize(width: Double(mode.width), height: Double(mode.height))
cachedAt = now
}
return cachedMode
}
streamView.onTouchEvent = { [weak self, weak connection] event in
// Touch IS the intent during a trusted session, but must not leak to the host
@@ -341,6 +354,26 @@ public final class StreamViewController: StreamViewControllerBase {
guard self?.captureEnabled == true else { return }
connection?.sendPen(batch)
}
// Pencil near the glass pin the panel (and with it UIKit's event cadence) at the
// link range's ceiling, so a sub-panel-rate stream stops halving pencil sampling.
// Engage is immediate; release waits 2 s so edge-of-canvas hover flicker can't
// thrash the link. MAIN thread (UIKit events + main-queue work item).
streamView.onPenProximity = { [weak self] near in
guard let self else { return }
self.penBoostRelease?.cancel()
self.penBoostRelease = nil
if near {
self.presenter.setInteractionBoost(true)
} else {
let release = DispatchWorkItem { [weak self] in
guard let self else { return }
self.penBoostRelease = nil
self.presenter.setInteractionBoost(false)
}
self.penBoostRelease = release
DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: release)
}
}
// Indirect pointer (mouse/trackpad) WITHOUT a lock absolute cursor + buttons + scroll.
// While the scene is pointer-LOCKED the GCMouse path owns motion AND buttons AND scroll, so
// the whole UIKit indirect path is gated off here (`gcMouseForwarding`). The trackpad and a
@@ -513,6 +546,9 @@ public final class StreamViewController: StreamViewControllerBase {
streamView.resetTouchInput()
streamView.onTouchEvent = nil
streamView.onPenBatch = nil // after reset the pen's leave-range sample rides it
streamView.onPenProximity = nil // after reset its leave-range transition fired above
penBoostRelease?.cancel()
penBoostRelease = nil
streamView.penEnabled = false
streamView.onPointerMoveAbs = nil
streamView.onPointerButton = nil
@@ -713,6 +749,8 @@ final class StreamLayerUIView: UIView {
/// The host advertised `HOST_CAP_PEN`, so Pencil input splits out of the finger path onto
/// the pen plane independent of the touch-input mode (drawing must not depend on it).
var penEnabled = false
/// Pencil proximity transitions (hover or contact) the presenter's panel-rate boost.
var onPenProximity: ((Bool) -> Void)?
/// Indirect pointer (mouse/trackpad with no lock) absolute cursor moves.
var onPointerMoveAbs: ((HostPoint) -> Void)?
/// Indirect-pointer buttons (GameStream ids: 1=left 3=right); `down` = press.
@@ -740,6 +778,7 @@ final class StreamLayerUIView: UIView {
private lazy var pencil: PencilStream = {
let stream = PencilStream()
stream.send = { [weak self] batch in self?.onPenBatch?(batch) }
stream.onProximity = { [weak self] near in self?.onPenProximity?(near) }
stream.videoNorm = { [weak self] point in
guard let h = self?.hostPoint(from: point) else { return nil }
return (Float(h.x) / Float(max(h.w - 1, 1)), Float(h.y) / Float(max(h.h - 1, 1)))
+194 -26
View File
@@ -59,7 +59,131 @@ punktfunk — the Punktfunk client, headless
A <host-ref> is a saved host's id, its name, or an address the same reference a
punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not found,
6 needs a person.";
6 needs a person.
\"punktfunk help <command>\" (or any command with --help) explains that command.";
/// The long help for one verb — `punktfunk help <verb>`, or `--help` after the verb.
/// Each entry documents its flags and the behaviour a script would need to know
/// (what goes to stdout vs stderr, and which exit codes mean what).
fn verb_help(verb: &str) -> Option<&'static str> {
Some(match verb {
"pair" => {
"\
punktfunk pair <host[:port]> enrol this device with a host (PIN ceremony)
--pin N the PIN the host is showing; without it the command asks, and
refuses (exit 6) when there is no terminal to ask on
--name LABEL the label the host files this device under
(default: this machine's name)
Pairing verifies the host end-to-end and pins its fingerprint in the saved-hosts
store, so every later connect here, in the desktop client or the console is
silent. The port defaults to 9777. Prints `paired <addr>:<port> fp=<hex>` on
success; exit 3 if the host refuses or the PIN is wrong."
}
"hosts" => {
"\
punktfunk hosts the saved-hosts store (shared with the desktop client)
punktfunk hosts list [--probe] [--json]
Every saved host, name TAB addr:port TAB paired/trusted TAB state.
--probe asks each host directly (no mDNS, so routed/VPN hosts answer
too); --json emits one object with per-host detail, profiles included.
punktfunk hosts add <host[:port]> [--name LABEL] [--fp HEX]
Save a host by address the door for a box mDNS never sees (Tailscale,
another subnet). Without --fp it is a placeholder to pair later; with a
64-hex fingerprint it is pinned immediately (still unpaired).
punktfunk hosts forget <host-ref>
Remove a saved host, its pinned fingerprint included. A later connect
must pair or trust it again."
}
"wake" => {
"\
punktfunk wake <host-ref> [--wait] Wake-on-LAN
Sends a magic packet to a saved host's MAC (learned from its advert while it
was awake; exit 5 if none is known yet). With --wait, keeps sending every 6 s
and polls presence every second for up to 90 s, exiting 0 the moment the host
answers the same cadence every graphical shell uses."
}
"library" => {
"\
punktfunk library <host-ref> [--json] the host's game library
TSV on stdout by default (id TAB store TAB title), one game per line; --json
emits {\"games\":[…]} for tools — the Playnite importer shells to exactly
this. Needs a paired host (exit 6 otherwise)."
}
"launch" => {
"\
punktfunk launch <host-ref> [--game ID] [--profile REF] [--exec] [--fullscreen]
Start a stream waking the host first if it is asleep and its MAC is known.
The stream runs in the punktfunk-session renderer; this command supervises it
and relays its lifecycle to stderr.
--game ID ask the host to launch this library title into the stream
--profile REF use a settings profile (id or name) for this connect only;
without it the host's own binding applies
--fullscreen start the stream window fullscreen
--exec become the session process instead of supervising it the
gamescope-wrapper mode, where the launched process must BE
the streaming one for focus and lifecycle to work
Exit 0 when the stream ends cleanly, 2 connect failed, 3 the host no longer
trusts this device (re-pair), 4 the renderer could not start."
}
"open" => {
"\
punktfunk open <punktfunk://…> — follow a punktfunk:// link, headless
Same parser and same refusal rules as clicking the link in a shell: a
contradicted fingerprint refuses and says so, an ambiguous name refuses
rather than guessing, and an unknown host is never trusted from a URL
that is a decision for a person, at a surface that can show the fingerprint
(exit 6 points at `punktfunk pair`). --exec as in launch."
}
"reachable" => {
"\
punktfunk reachable <host-ref> one bounded reachability probe
Asks the host directly (no mDNS), so routed/VPN hosts answer too. The
reference may be an unsaved address this verb answers \"can I reach it\",
not \"do I know it\". Exit 0 reachable, 2 not; one line either way."
}
"speed-test" => {
"\
punktfunk speed-test <host-ref> [--json] measure the real data plane
Runs the host's bandwidth probe over an actual session connect and prints the
measured throughput, loss, and the bitrate it recommends. Deliberately does
NOT apply the result: which layer a bitrate belongs in (a bound profile, the
global default) is a decision the GUI makes with the user, and a CLI silently
rewriting settings would be exactly the surprise that rule exists to prevent."
}
"profiles" => {
"\
punktfunk profiles list [--json] the settings profiles on this device
One line per profile: id TAB name TAB how many settings it overrides.
Profiles are created and edited in the desktop client; a connect uses one via
`punktfunk launch --profile` or a punktfunk:// link that names it."
}
"reset" => {
"\
punktfunk reset forget every saved host and reset stream settings
Asks for confirmation, and refuses (exit 6) when there is no terminal to ask
on. This device's identity keypair is deliberately kept, so hosts that knew
this machine still recognise it after re-pairing; delete the identity files
from the config directory for a true factory reset."
}
_ => return None,
})
}
/// The value after `--flag`, if any.
fn value(args: &[String], flag: &str) -> Option<String> {
@@ -138,6 +262,12 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
return OK;
};
let rest: Vec<String> = args[1..].to_vec();
// `--help`/`-h` after any verb prints that verb's help — before dispatch, so a verb
// never mistakes the flag for its subject (`punktfunk pair -h` must not dial "-h").
if rest.iter().any(|a| a == "--help" || a == "-h") {
println!("{}", verb_help(&verb).unwrap_or(USAGE));
return OK;
}
match verb.as_str() {
"pair" => pair(&rest),
"hosts" => hosts(&rest),
@@ -149,10 +279,22 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
"speed-test" => speed_test(&rest),
"profiles" => profiles(&rest),
"reset" => reset(),
"-h" | "--help" | "help" => {
println!("{USAGE}");
OK
}
"-h" | "--help" | "help" => match positional(&rest, 0) {
None => {
println!("{USAGE}");
OK
}
Some(topic) => match verb_help(&topic) {
Some(h) => {
println!("{h}");
OK
}
None => {
eprintln!("no command called \"{topic}\"\n\n{USAGE}");
UNRESOLVED
}
},
},
"--version" | "version" => {
println!("punktfunk {}", env!("CARGO_PKG_VERSION"));
OK
@@ -600,10 +742,17 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
return UNRESOLVED;
};
// An address that isn't saved is still a legitimate thing to probe — this verb answers
// "can I reach this?", not "do I know this?".
let (addr, port) = match resolve(&reference) {
Ok((known, i)) => (known.hosts[i].addr.clone(), known.hosts[i].port),
Err(_) => split_host_port(&reference),
// "can I reach this?", not "do I know this?". Resolved QUIETLY for the same reason:
// `resolve`'s "pair it first" advice is for verbs that need a saved host, and printing
// it here would scold the exact usage this verb documents.
let known = KnownHosts::load();
let link = DeepLink {
host_ref: reference.clone(),
..Default::default()
};
let (addr, port) = match deeplink::resolve_host(&link, &known) {
HostResolution::Known(i) => (known.hosts[i].addr.clone(), known.hosts[i].port),
_ => split_host_port(&reference),
};
if punktfunk_core::client::NativeClient::probe(&addr, port, PROBE_TIMEOUT) {
println!("reachable {addr}:{port}");
@@ -649,14 +798,15 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
},
punktfunk_core::config::CompositorPref::Auto,
punktfunk_core::config::GamepadPref::Auto,
0, // bitrate_kbps: the host's default; this connect never presents
0, // video_caps: nothing decodes here
2, // audio_channels
0, // video_codecs: the probe carries no video
0, // preferred_codec
None, // display_hdr
0, // client_caps: nothing renders a cursor
None, // launch
0, // bitrate_kbps: the host's default; this connect never presents
0, // video_caps: nothing decodes here
2, // audio_channels
0, // video_codecs: the probe carries no video
0, // preferred_codec
None, // display_hdr
0, // client_caps: nothing renders a cursor
false, // frame_parts: probe/whole-AU consumer
None, // launch
Some(punktfunk_core::client::device_name()),
Some(pin),
Some(identity),
@@ -769,15 +919,7 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
/// Is stdin a terminal? Decides whether a verb may ask a question or must refuse with
/// [`NEEDS_INTERACTION`] — a CLI that blocks a CI job on a prompt is a hang, not a UX.
fn is_tty() -> bool {
#[cfg(unix)]
{
// SAFETY-free check: `isatty` via the std file descriptor, without libc.
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
#[cfg(not(unix))]
{
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
std::io::IsTerminal::is_terminal(&std::io::stdin())
}
#[cfg(test)]
@@ -820,6 +962,32 @@ punktfunk:// link takes. Exit codes: 0 ok, 2 connect, 3 trust, 4 renderer, 5 not
assert_eq!(split_host_port("desk:nope"), ("desk:nope".into(), 9777));
}
/// Every advertised verb documents itself — a USAGE line without a help entry is a
/// promise `help <verb>` breaks. The overview and each entry must also name the verb.
#[test]
fn every_usage_verb_has_help() {
for verb in [
"pair",
"hosts",
"wake",
"library",
"launch",
"open",
"reachable",
"speed-test",
"profiles",
"reset",
] {
let h = verb_help(verb).unwrap_or_else(|| panic!("no help for {verb}"));
assert!(
h.starts_with(&format!("punktfunk {verb}")),
"help for {verb} must lead with its own invocation"
);
assert!(USAGE.contains(verb), "USAGE must advertise {verb}");
}
assert!(verb_help("bogus").is_none());
}
#[test]
fn value_reads_the_argument_after_its_flag() {
let a = argv(&["--game", "steam:570", "--exec"]);
+69
View File
@@ -0,0 +1,69 @@
//! Runs the REAL `punktfunk` binary and pins its self-documentation contract: help goes
//! to stdout and exits 0, an unknown verb refuses on stderr with the not-found code.
//!
//! This exists so CI *executes* the shipped binary at least once per platform. A binary
//! that compiles but is the wrong program passes every build/clippy/fmt gate — that is
//! exactly how 0.22.0 shipped a stub as `punktfunk-session` — and only a gate that runs
//! the thing catches the class. The help paths are the right probe: they touch no config
//! stores and no network, so they are safe on any runner.
#![cfg(any(target_os = "linux", windows))]
use std::process::Command;
fn punktfunk(args: &[&str]) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_punktfunk"))
.args(args)
.output()
.expect("run punktfunk")
}
#[test]
fn bare_and_help_print_the_overview_on_stdout() {
for args in [&[][..], &["help"][..], &["--help"][..], &["-h"][..]] {
let out = punktfunk(args);
assert!(out.status.success(), "{args:?} must exit 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("punktfunk pair"),
"{args:?} overview lists the verbs"
);
assert!(
stdout.contains("help <command>"),
"{args:?} overview points at per-verb help"
);
}
}
#[test]
fn per_verb_help_answers_both_spellings() {
for args in [
&["help", "launch"][..],
&["launch", "--help"][..],
&["launch", "-h"][..],
] {
let out = punktfunk(args);
assert!(out.status.success(), "{args:?} must exit 0");
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("--exec"),
"{args:?} documents launch's flags"
);
}
}
#[test]
fn version_prints_the_crate_version() {
let out = punktfunk(&["--version"]);
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn unknown_verbs_refuse_with_the_not_found_code() {
let out = punktfunk(&["frobnicate"]);
assert_eq!(out.status.code(), Some(5), "unknown verb exits 5");
assert!(String::from_utf8_lossy(&out.stderr).contains("unknown command"));
let out = punktfunk(&["help", "frobnicate"]);
assert_eq!(out.status.code(), Some(5), "unknown help topic exits 5");
}
+19
View File
@@ -54,6 +54,25 @@ Loader's own (SHA-256-verified) install. Installs and updates can take a couple
networks: Decky's installer also contacts its plugin store first, which may be slow or blackholed
before the actual download proceeds.
### Updating the client
The plugin also reports — and where it can, installs — updates for the **client** it launches.
What is possible depends on how that client was installed, and the About tab names the install
kind so the answer is never a mystery:
| Install | Update |
| --- | --- |
| **Flatpak** (the usual Deck client) | One tap. `flatpak update --user io.unom.Punktfunk` — a per-user install, which is why `sudo flatpak update` never touches it. |
| **.deb / .rpm** (and rpm-ostree, which stages for the next reboot) | One tap, *after* an explicit opt-in: `sudo usermod -aG punktfunk-update $USER`. The tap starts a fixed, parameterless root oneshot (`punktfunk-client-update.service`) through polkit — nothing about the request is attacker-influenceable, and the payload comes from your distro's own signed repositories. |
| **pacman** | Same, plus the root-owned `PACMAN_FULL_SYSUPGRADE=1` in `/etc/punktfunk/update.conf` — a partial upgrade is against Arch doctrine, so the only thing the helper will run is a full `pacman -Syu`. |
| **sysext, nix, a source build** | The plugin shows the command and stops. There is no feed behind those installs, and a button that can only fail is worse than one honest line. |
Whether a *newer* client exists is the client's own answer (`punktfunk-client --check-update`),
read from the Ed25519-signed per-channel manifest the host's update check already trusts —
`PUNKTFUNK_UPDATE_CHECK=0` disables the check, `PUNKTFUNK_UPDATE_APPLY=0` keeps the check but
never offers to install. A client too old to have that mode is reported as such rather than as
up to date.
## Build & sideload (development)
```sh
+165 -15
View File
@@ -22,8 +22,14 @@ The backend's jobs are the things Steam can't do:
* **get_settings() / set_settings()** read/write the flatpak client's stream settings JSON
(resolution / bitrate / gamepad), so the Deck UI configures the stream the client reads.
* **kill_stream()** force-stop a wedged stream (``flatpak kill``).
* **check_update()** poll the registry's per-channel ``manifest.json`` and report whether a
newer build is available (the frontend then drives Decky's own install RPC to apply it).
* **check_update()** report pending updates for BOTH the plugin and the client. The plugin's
comes from the registry's per-channel ``manifest.json`` (the frontend then drives Decky's own
install RPC to apply it); the client's depends on how it was installed — a flatpak is compared
by OSTree commit here, anything else is asked of the client itself
(``punktfunk-client --check-update``, which verifies a signed manifest).
* **update_client()** apply the client update by whichever route that install supports:
``flatpak update --user``, ``punktfunk-client --apply-update`` (the packaged root helper), or
a refusal carrying the command to run by hand.
The TXT-record keys parsed (``proto`` / ``fp`` / ``pair`` / ``id`` / ``mgmt``) are defined by
the host advert in ``crates/punktfunk-host/src/discovery.rs``.
@@ -392,6 +398,18 @@ def _client_argv() -> list[str] | None:
return [native] if native else None
def _client_is_flatpak() -> bool:
"""Is the client this plugin actually drives the FLATPAK one?
Not the same question as "is the flatpak installed": `PF_DECKY_CLIENT=native` forces the
native binary on a box that has both, and the update check has to describe the client the
launcher will really run otherwise a Deck with both would be offered a flatpak update for
a client it never starts.
"""
prefix = _client_argv()
return bool(prefix) and prefix[0] == _flatpak()
def _flatpak_env() -> dict:
"""Environment for a headless client run from the backend (no display needed for pairing).
Reconstruct the user-session bits flatpak wants; the backend may not inherit them. Harmless
@@ -562,7 +580,12 @@ async def _client_update_state() -> dict:
**per-user** install (so ``sudo flatpak update``, which is system-scope, never touches it), and
it versions independently of this plugin so we compare the installed commit against the
remote's here and let the QAM offer a user-scope update. Best-effort; all-``False`` on any error
(not installed, no flatpak, offline)."""
(not installed, no flatpak, offline).
Flatpak keeps its OWN comparison (commits, not versions) because it is the exact one: a
flatpak built from main between releases carries the release's crate version, so the
signed-manifest comparison the native path uses would call it up to date when it isn't.
Native installs have no commit to compare and go through :func:`_native_update_state`."""
state = {"available": False, "installed": "", "remote": ""}
rc, info = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
if rc != 0:
@@ -581,6 +604,50 @@ async def _client_update_state() -> dict:
return state
# --- native (non-flatpak) client updates ----------------------------------------------------
#
# A .deb/.rpm/pacman/sysext/nix client is not something this plugin can reason about on its own:
# working out whether a newer build exists means fetching a per-channel manifest and verifying
# its Ed25519 signature, and Decky's embedded Python has no crypto library to do that with (nor
# should the trust rule live in two languages). So the CLIENT answers both questions —
# `--check-update --json` says what is available and who could install it, `--apply-update`
# drives the packaged root helper — and this backend is a UI over those, exactly as it already
# is for `--pair` / `--library` / `--list-hosts`.
#
# Shape of `--check-update --json` (pf_client_core::update::Status):
# {kind, channel, current, latest, update_available, apply, applier, command,
# opt_in_hint?, notes_url, error?}
# `applier` is what this file routes on: "flatpak" (we run flatpak), "helper" (the client runs
# the root helper), "none" (show `command` — nothing here can install it).
async def _native_update_state() -> dict:
"""Ask a NATIVE client whether a newer build exists for its channel. Returns the client's
own status dict, or ``{}`` when it couldn't be asked (no native client, a client too old to
have the mode, offline). Best-effort by design: an unanswerable check must read as "can't
tell", never as "up to date"."""
rc, out, err = await _run_client(["--check-update", "--json"], timeout=30.0)
# The JSON is authoritative whenever there IS JSON, whatever the exit code: the client
# exits 0 up-to-date, 10 update-available, and 1 when the check failed — but in that last
# case it STILL prints a status carrying `error` plus the install kind and the command for
# this box, which is exactly what the UI needs to explain itself. Reading only the exit
# code would throw all of that away and report a bare "couldn't check".
if out.strip():
try:
data = json.loads(out)
if isinstance(data, dict):
return data
except json.JSONDecodeError:
decky.logger.warning("check-update: unparseable output: %s", out[:200])
if rc == -1:
return {}
# A client predating `--check-update` ignores the flag and falls through to GTK init, which
# fails headless — the same signature the other headless modes classify.
code = _classify_library_error(err)
decky.logger.info("native check-update unavailable (rc=%s, %s)", rc, code)
return {"error": code} if code == "client-outdated" else {}
def _split_txt(txt: str) -> list[str]:
"""Split an avahi TXT column into tokens, honouring the ``"key=value"`` quoting."""
tokens: list[str] = []
@@ -1138,14 +1205,71 @@ class Plugin:
return {"ok": False}
return {"ok": True}
async def _update_native_client(self) -> dict:
"""The non-flatpak leg of :meth:`update_client` — drive the client's own
``--apply-update``, which starts the packaged root helper.
The timeout is generous because a package-manager run on a stale box is slow; the
client caps its own wait at 30 min, so this one sits below that and reports a timeout
rather than hanging the QAM forever.
"""
state = await _native_update_state()
if not state:
# No client answered at all (none installed, or one too old for the mode) — say
# that, rather than "this install updates by hand", which would be a guess.
return {"ok": False, "updated": False, "error": "client-unavailable"}
applier = state.get("applier")
if applier != "helper":
# Nothing here can install it — hand back the command the client computed, so the
# UI shows one true line instead of guessing per install kind.
return {
"ok": False,
"updated": False,
"error": "manual",
"command": state.get("opt_in_hint") or state.get("command", ""),
}
rc, out, err = await _run_client(["--apply-update", "--json"], timeout=900.0)
if rc == -1:
return {"ok": False, "updated": False, "error": "timeout"}
outcome: dict = {}
if out.strip():
try:
outcome = json.loads(out)
except json.JSONDecodeError:
pass
if not outcome.get("ok"):
detail = outcome.get("error") or (err.strip().splitlines() or ["update failed"])[-1]
decky.logger.warning("native client update failed (rc=%s): %s", rc, detail)
return {"ok": False, "updated": False, "error": "update-failed", "detail": detail}
_update_cache["data"] = None # invalidate the cached "update available" snapshot
decky.logger.info(
"native client update: %s -> %s (changed=%s, staged=%s)",
outcome.get("before", ""), outcome.get("after", ""),
outcome.get("changed"), outcome.get("staged"),
)
return {
"ok": True,
"updated": bool(outcome.get("changed")),
"staged": bool(outcome.get("staged")),
}
async def update_client(self) -> dict:
"""Update the flatpak **client** (io.unom.Punktfunk) in the USER installation — the scope a
Steam Deck install lives in, which ``sudo flatpak update`` (system-scope) never reaches.
Returns whether a new commit was actually pulled. Best-effort; non-fatal. A NATIVE client
is updated by whatever installed it (distro package manager, sysext, nix), never here
`check_update` reports no client update for one, so the UI never offers this."""
if not _flatpak_installed():
return {"ok": False, "updated": False, "error": "flatpak-not-found"}
"""Update the **client**, by whichever route this box's install actually supports.
* **flatpak** ``flatpak update --user`` in the USER installation, the scope a Steam
Deck install lives in and which ``sudo flatpak update`` (system-scope) never reaches.
* **native, one-tap capable** (.deb / .rpm / pacman with the packaged root helper and
the operator's group opt-in) — ``punktfunk-client --apply-update``, which starts the
fixed, parameterless ``punktfunk-client-update.service`` through polkit. This backend
passes nothing to it; the helper derives everything from root-owned state.
* **anything else** (sysext, nix, a source build, no opt-in) refused with the exact
command to run, which the UI shows. `check_update` reports the same, so the UI knows
not to offer a button in the first place.
Returns ``{ok, updated, error?, detail?, command?, staged?}``. Best-effort; non-fatal.
"""
if not _client_is_flatpak():
return await self._update_native_client()
_, before = await _flatpak_capture(["info", "--user", APP_ID], timeout=10.0)
before_commit = _field_from(before, "Commit")
rc, out = await _flatpak_capture(["update", "--user", "-y", APP_ID], timeout=300.0)
@@ -1183,6 +1307,13 @@ class Plugin:
"client_update_available": False,
"client_current": "",
"client_latest": "",
# How the client got here (`flatpak`, `apt`, `dnf`, `sysext`, `nix`, `source`, …),
# who could install an update (`flatpak` | `helper` | `none`), and the one line that
# does it by hand. Empty on a flatpak-only box that never reaches the native path.
"client_install": "",
"client_applier": "",
"client_command": "",
"client_opt_in": "",
}
now = time.monotonic()
@@ -1190,12 +1321,31 @@ class Plugin:
if not force and cached and (now - _update_cache["at"]) < _UPDATE_TTL_S:
return cached
# Client (flatpak) update — checked ALWAYS, even on a dev/sideloaded plugin build.
# Client update — checked ALWAYS, even on a dev/sideloaded plugin build. Which check
# runs depends on how the client was installed: the flatpak compares OSTree commits
# (exact for a per-user flatpak), everything else asks the client itself, which
# verifies the signed per-channel manifest. See _client_update_state / _native_update_state.
try:
cu = await _client_update_state()
result["client_update_available"] = bool(cu["available"])
result["client_current"] = (cu["installed"] or "")[:10]
result["client_latest"] = (cu["remote"] or "")[:10]
if _client_is_flatpak():
cu = await _client_update_state()
result["client_update_available"] = bool(cu["available"])
result["client_current"] = (cu["installed"] or "")[:10]
result["client_latest"] = (cu["remote"] or "")[:10]
result["client_install"] = "flatpak"
result["client_applier"] = "flatpak"
result["client_command"] = f"flatpak update --user {APP_ID}"
else:
nu = await _native_update_state()
result["client_update_available"] = bool(nu.get("update_available"))
result["client_current"] = str(nu.get("current", ""))
result["client_latest"] = str(nu.get("latest", ""))
result["client_install"] = str(nu.get("kind", ""))
result["client_applier"] = str(nu.get("applier", ""))
result["client_command"] = str(nu.get("command", ""))
result["client_opt_in"] = str(nu.get("opt_in_hint", "") or "")
if nu.get("error"):
# "Couldn't tell" — never rendered as up to date; the UI shows the reason.
result["client_error"] = str(nu["error"])
except Exception: # noqa: BLE001
decky.logger.warning("client update check failed", exc_info=True)
+25 -6
View File
@@ -124,11 +124,20 @@ export interface UpdateInfo {
hash: string; // sha256 of that zip (Decky verifies it)
channel: string; // "latest" (stable) | "canary"
update_available: boolean; // a newer PLUGIN build is available
// The flatpak CLIENT (io.unom.Punktfunk) versions independently and is a per-user install, so
// `sudo flatpak update` never touches it — the plugin offers a user-scope update instead.
// The CLIENT versions independently of this plugin, and how it updates depends on how it was
// installed. A flatpak is a per-user install `sudo flatpak update` never touches, compared by
// OSTree commit; every other install (.deb/.rpm/pacman/sysext/nix/source) is compared by the
// client itself against the signed per-channel manifest (`punktfunk-client --check-update`).
client_update_available: boolean;
client_current: string; // installed client commit (short) — informational
client_latest: string; // remote client commit (short) — informational
client_current: string; // installed client commit (flatpak) or version (native)
client_latest: string; // newest client commit (flatpak) or version (native)
client_install: string; // "flatpak" | "apt" | "dnf" | "pacman" | "sysext" | "nix" | "source" | ""
// Who can perform the update: "flatpak" (this plugin runs it), "helper" (the client drives the
// packaged root helper), "none" (nothing here can — show `client_command`).
client_applier: string;
client_command: string; // one copy-pastable line that updates this install by hand
client_opt_in: string; // set when one-tap WOULD work after `usermod -aG punktfunk-update`
client_error?: string; // the client check couldn't complete (e.g. "client-outdated")
error?: string; // "update-channel-unknown" (dev build) | "fetch-failed"
}
@@ -206,8 +215,18 @@ export const probeHost = callable<
{ ok: boolean; online?: boolean; error?: string }
>("probe_host");
export const checkUpdate = callable<[force: boolean], UpdateInfo>("check_update");
// Update the flatpak client in the user installation (`flatpak update --user -y io.unom.Punktfunk`).
// Update the client by whichever route its install supports: `flatpak update --user` for the
// flatpak, `punktfunk-client --apply-update` (the packaged root helper) for a one-tap-capable
// native install. Everything else comes back `ok: false, error: "manual"` with `command` — the
// line to run by hand. A package-manager run can take minutes; the backend allows 15.
export const updateClient = callable<
[],
{ ok: boolean; updated: boolean; error?: string }
{
ok: boolean;
updated: boolean;
staged?: boolean; // installed, but a reboot activates it (rpm-ostree)
error?: string;
detail?: string;
command?: string; // set with error "manual"
}
>("update_client");
+114 -17
View File
@@ -240,11 +240,61 @@ export function useUpdate() {
return { info, checking, check };
}
/** True when EITHER the plugin or the flatpak client has a pending update. */
/** True when EITHER the plugin or the client has a pending update. */
export function hasUpdate(info: UpdateInfo | null | undefined): boolean {
return !!info && (info.update_available || info.client_update_available);
}
/**
* Can this Deck actually INSTALL the pending client update, or only tell you how?
*
* A flatpak and a one-tap-capable native install (the packaged root helper + the operator's
* group opt-in) get a button; a sysext, a nix profile, a source build or a box that hasn't
* opted in gets the command. Offering a button that can only fail is worse than saying so.
*/
export function clientUpdateIsOneTap(info: UpdateInfo | null | undefined): boolean {
return (
!!info &&
info.client_update_available &&
(info.client_applier === "flatpak" || info.client_applier === "helper")
);
}
/**
* How the client got onto this box, in words a Deck user recognises. The raw kind comes from
* the client's own detector (`pf_update_check::detect`); anything unmapped falls through as
* itself rather than as "unknown", because the raw word is still more useful than a shrug.
*/
export function clientInstallLabel(kind: string): string {
switch (kind) {
case "flatpak":
return "Flatpak (per-user)";
case "apt":
return "System package (apt)";
case "dnf":
return "System package (dnf)";
case "rpm-ostree":
return "Layered package (rpm-ostree)";
case "pacman":
return "System package (pacman)";
case "sysext":
return "System extension (sysext)";
case "nix":
return "Nix profile";
case "steamos-source":
return "On-device build";
case "source":
return "Built from source";
default:
return kind;
}
}
/** True when the only pending update is one this Deck can't apply itself. */
export function clientUpdateIsManualOnly(info: UpdateInfo | null | undefined): boolean {
return !!info && info.client_update_available && !clientUpdateIsOneTap(info);
}
/** The explicit "Check for updates" action — always ends in a toast so the tap has feedback. */
export async function checkForUpdatesNow(
check: (force: boolean) => Promise<UpdateInfo | null>,
@@ -256,8 +306,20 @@ export async function checkForUpdatesNow(
} else if (hasUpdate(res)) {
const parts: string[] = [];
if (res.update_available) parts.push(`plugin v${res.current} → v${res.latest}`);
if (res.client_update_available) parts.push("client");
if (res.client_update_available) {
parts.push(res.client_latest ? `client ${res.client_latest}` : "client");
}
body = `Update available: ${parts.join(" + ")}.`;
if (clientUpdateIsManualOnly(res)) {
// Say the honest thing up front rather than letting the user find out at the button.
body += " The client updates outside Punktfunk on this install.";
}
} else if (res.client_error) {
// A failed CLIENT check must never read as "up to date" — that is the one wrong answer.
body =
res.client_error === "client-outdated"
? "Couldnt check the client — it predates update checks. Update it once by hand."
: "Couldnt check the client for updates.";
} else if (res.error === "update-channel-unknown") {
body = "Development build — plugin updates are disabled; the client is up to date.";
} else {
@@ -266,32 +328,67 @@ export async function checkForUpdatesNow(
toaster.toast({ title: "Punktfunk", body });
}
/** One line of user-facing copy for whatever `updateClient()` came back with. */
function clientUpdateResultBody(r: Awaited<ReturnType<typeof updateClient>>): string {
if (r.ok) {
if (r.staged) return "Client updated — reboot to finish.";
return r.updated ? "Client updated to the latest version." : "Client is already up to date.";
}
// "manual" is not a failure: the box simply can't install it, and `command` says how.
if (r.error === "manual") {
return r.command
? `This client updates outside Punktfunk. Run: ${r.command}`
: "This client updates outside Punktfunk — use the way you installed it.";
}
if (r.error === "timeout") return "Client update timed out — check the box and try again.";
if (r.error === "client-unavailable")
return "Couldnt reach the client to update it — is it still installed?";
return `Client update failed${r.detail ? `: ${r.detail}` : r.error ? ` (${r.error})` : ""}.`;
}
/**
* Apply whichever updates are pending. The flatpak CLIENT is updated first (a user-scope
* `flatpak update`, awaited); then, if the PLUGIN itself has an update, Decky's install RPC
* reinstalls it which reloads the plugin and tears this panel down, so it goes last and is
* fire-and-forget. `check` (when passed) refreshes the panel state after a client-only update so
* the "Update available" button clears.
* Apply whichever updates are pending.
*
* The CLIENT goes first and is awaited, by whichever route its install supports a user-scope
* `flatpak update`, or the packaged root helper via `punktfunk-client --apply-update`. An
* install neither can serve is not attempted at all: the user gets the command in a toast,
* because a button that can only fail teaches nothing.
*
* The PLUGIN goes last and is fire-and-forget: Decky's install RPC reinstalls and reloads the
* plugin, tearing this panel down before any result could arrive. `check` (when passed)
* refreshes the panel state after a client-only update so the "Update available" button clears.
*/
export async function applyUpdate(
info: UpdateInfo,
check?: (force: boolean) => Promise<UpdateInfo | null>,
): Promise<void> {
if (info.client_update_available) {
toaster.toast({ title: "Punktfunk", body: "Updating the client…" });
if (info.client_update_available && clientUpdateIsOneTap(info)) {
toaster.toast({
title: "Punktfunk",
// A package-manager run is not instant; say so before the wait, not after.
body:
info.client_applier === "helper"
? "Updating the client — this can take a few minutes…"
: "Updating the client…",
});
try {
const r = await updateClient();
toaster.toast({
title: "Punktfunk",
body: !r.ok
? `Client update failed${r.error ? ` (${r.error})` : ""}.`
: r.updated
? "Client updated to the latest version."
: "Client is already up to date.",
});
toaster.toast({ title: "Punktfunk", body: clientUpdateResultBody(r) });
} catch {
toaster.toast({ title: "Punktfunk", body: "Client update failed." });
}
} else if (info.client_update_available) {
// Nothing here can install it — hand over the one line that does, rather than a button
// that would fail. `client_opt_in` wins when joining the group is what's missing, since
// that is the step that turns this into a one-tap update from then on.
const line = info.client_opt_in || info.client_command;
toaster.toast({
title: "Punktfunk",
body: line
? `Client update available (${info.client_latest}). Run: ${line}`
: `A newer client (${info.client_latest}) is available — update it the way you installed it.`,
duration: 12_000,
});
}
if (info.update_available) {
+37 -21
View File
@@ -25,6 +25,7 @@ import { PluginErrorBoundary } from "./boundary";
import {
applyUpdate,
checkForUpdatesNow,
clientUpdateIsManualOnly,
hasUpdate,
mergeHosts,
needsPair,
@@ -72,27 +73,42 @@ const QamPanel: FC = () => {
return (
<>
{hasUpdate(update) && (
<PanelSection title="Update available">
<PanelSectionRow>
<ButtonItem
layout="below"
onClick={() => applyUpdate(update!, check)}
label={
update!.update_available
? `Plugin v${update!.current} → v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: "New client version"
}
description="Installing can take a couple of minutes"
>
<FaDownload style={{ marginRight: "0.5em" }} />
Update Punktfunk
</ButtonItem>
</PanelSectionRow>
</PanelSection>
)}
{hasUpdate(update) &&
// A client this Deck can't install (a sysext, a nix profile, a source build, or a box
// that hasn't opted into one-tap updates) gets the command, not a button — tapping
// something that can only fail is worse than reading one line. A pending PLUGIN update
// still wins the button, since that half always works.
(clientUpdateIsManualOnly(update) && !update!.update_available ? (
<PanelSection title="Client update available">
<PanelSectionRow>
<Field
focusable
label={update!.client_latest || "Newer version"}
description={update!.client_opt_in || update!.client_command}
/>
</PanelSectionRow>
</PanelSection>
) : (
<PanelSection title="Update available">
<PanelSectionRow>
<ButtonItem
layout="below"
onClick={() => applyUpdate(update!, check)}
label={
update!.update_available
? `Plugin v${update!.current} → v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: "New client version"
}
description="Installing can take a couple of minutes"
>
<FaDownload style={{ marginRight: "0.5em" }} />
Update Punktfunk
</ButtonItem>
</PanelSectionRow>
</PanelSection>
))}
<PanelSection title="Punktfunk">
<PanelSectionRow>
+89 -33
View File
@@ -1,40 +1,82 @@
// The host row's OS mark, resolved from the host's OS-identity chain (mDNS `os` TXT /
// `--list-hosts` `os`, e.g. "linux/fedora/bazzite"): walk the chain most-specific-first and
// take the first token react-icons has a brand mark for, so an unknown distro degrades to its
// family's mark and finally to Tux. Mirrors pf-client-core's `os_icon_tokens` (aliases
// macos→apple, steamos→steam); null when the chain is absent or entirely unknown — the row
// then renders exactly as it did before the field existed.
// take the first token we ship a mark for, so an unknown distro degrades to its family's
// mark and finally to Tux. Mirrors pf-client-core's `os_icon_tokens` (aliases macos→apple,
// steamos→steam); null when the chain is absent or entirely unknown — the row then renders
// exactly as it did before the field existed.
//
// Path data is transcribed from the assets/os-icons masters rather than pulled from
// react-icons: that package's Font Awesome 5 brand set still carries the perspective-skewed
// Windows flag, and it has no Bazzite or CachyOS mark at all. `bash scripts/gen-os-icons.sh
// <token>` prints a master's viewBox + path ready to paste here.
import { FC } from "react";
import {
FaApple,
FaFedora,
FaLinux,
FaSteam,
FaSuse,
FaUbuntu,
FaWindows,
} from "react-icons/fa";
import { SiArchlinux, SiDebian, SiNixos } from "react-icons/si";
import { IconType } from "react-icons";
const OS_ICONS: Record<string, IconType> = {
windows: FaWindows,
apple: FaApple,
macos: FaApple,
linux: FaLinux,
steam: FaSteam,
steamos: FaSteam,
ubuntu: FaUbuntu,
fedora: FaFedora,
opensuse: FaSuse,
arch: SiArchlinux,
debian: SiDebian,
nixos: SiNixos,
/** One monochrome brand mark: original per-icon viewBox, drawn in currentColor. */
const OS_ICONS: Record<string, { viewBox: string; d: string }> = {
windows: {
viewBox: "0 0 24 24",
d: "M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z",
},
apple: {
viewBox: "0 0 384 512",
d: "M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z",
},
linux: {
viewBox: "0 0 448 512",
d: "M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z",
},
steam: {
viewBox: "0 0 496 512",
d: "M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z",
},
ubuntu: {
viewBox: "0 0 496 512",
d: "M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z",
},
fedora: {
viewBox: "0 0 448 512",
d: "M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z",
},
arch: {
viewBox: "0 0 24 24",
d: "M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091",
},
debian: {
viewBox: "0 0 24 24",
d: "M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83",
},
nixos: {
viewBox: "0 0 24 24",
d: "M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z",
},
opensuse: {
viewBox: "0 0 640 512",
d: "M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z",
},
// The gaming distros get their own mark rather than their family's: on a Deck, "a Bazzite
// box" and "a Fedora box" are different machines to the person reading the row.
bazzite: {
viewBox: "0 0 24 24",
d: "M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z",
},
cachyos: {
viewBox: "0 0 24 24",
d: "M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81",
},
nobara: {
viewBox: "0 0 24 24",
d: "M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z",
},
};
export function resolveOsIcon(os: string | undefined): IconType | null {
for (const token of (os ?? "").toLowerCase().split("/").reverse()) {
const icon = OS_ICONS[token];
/** `macos` renders the apple mark, `steamos` the steam mark — same aliases as every client. */
const ALIASES: Record<string, string> = { macos: "apple", steamos: "steam" };
export function resolveOsIcon(
os: string | undefined,
): { viewBox: string; d: string } | null {
for (const raw of (os ?? "").toLowerCase().split("/").reverse()) {
const icon = OS_ICONS[ALIASES[raw] ?? raw];
if (icon) {
return icon;
}
@@ -44,6 +86,20 @@ export function resolveOsIcon(os: string | undefined): IconType | null {
/** The mark itself, or nothing — sized/colored by the surrounding text like the lock glyph. */
export const OsMark: FC<{ os?: string }> = ({ os }) => {
const Icon = resolveOsIcon(os);
return Icon ? <Icon /> : null;
const icon = resolveOsIcon(os);
if (!icon) return null;
// 1em square with the viewBox letterboxed inside it: the same box react-icons drew, so a
// non-square mark keeps its aspect ratio instead of being stretched to fill.
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox={icon.viewBox}
height="1em"
width="1em"
fill="currentColor"
aria-hidden
>
<path d={icon.d} />
</svg>
);
};
+38 -8
View File
@@ -37,6 +37,8 @@ import {
PinsApi,
applyUpdate,
checkForUpdatesNow,
clientInstallLabel,
clientUpdateIsManualOnly,
hasUpdate,
mergeHosts,
needsPair,
@@ -391,6 +393,16 @@ const AboutTab: FC<{
</DialogButton>
</RowActions>
</Field>
{/* What the client IS, so "why is there no Update button?" has a visible answer. The
install kind decides everything below it. */}
{!!update?.client_install && (
<Field
label="Client"
description={`${clientInstallLabel(update.client_install)}${
update.client_current ? ` · ${update.client_current}` : ""
}`}
/>
)}
{hasUpdate(update) && (
<Field
label={
@@ -398,19 +410,37 @@ const AboutTab: FC<{
? `Plugin update — v${update!.latest}${
update!.client_update_available ? " + client" : ""
}`
: "Client update available"
: `Client update ${update!.client_latest || "available"}`
}
description={
// Only promise a one-tap install when there is one. On a notify-only install the
// row becomes the command itself, which is the whole answer for that box.
clientUpdateIsManualOnly(update) && !update!.update_available
? update!.client_opt_in || update!.client_command
: "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"
>
<RowActions>
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</RowActions>
{clientUpdateIsManualOnly(update) && !update!.update_available ? null : (
<RowActions>
<DialogButton style={actionButton} onClick={() => applyUpdate(update!, check)}>
<FaDownload style={{ marginRight: "0.4em" }} />
Update
</DialogButton>
</RowActions>
)}
</Field>
)}
{!!update?.client_error && (
<Field
label="Client update check"
description={
update.client_error === "client-outdated"
? "This client predates update checks — update it once by hand and the check starts working."
: "Couldnt check the client for updates."
}
/>
)}
<Field
label="Setup guide"
description="Hosts, pairing, controllers, and troubleshooting — docs.punktfunk.unom.io"
+15 -1
View File
@@ -1,7 +1,21 @@
//! Compile the shell's embedded assets (`data/` — the host-card OS-mark symbolic icons)
//! into a gresource bundle, registered at startup via `gio::resources_register_include!`.
//! into a gresource bundle, registered at startup via `gio::resources_register_include!`,
//! and stamp the build version the update check compares against.
fn main() {
// Build provenance, identical to the host's (crates/punktfunk-host/build.rs): the packaging
// jobs set PUNKTFUNK_BUILD_VERSION to the full package version (`0.23.0~ci10250.gab12cd34`
// on deb, `0.23.0-0.ci10250.g…` on rpm, …), a plain `cargo build` falls back to the crate
// version. This is what `--version` prints and what `--check-update` compares against the
// signed manifest — and the canary suffix is load-bearing there, because canary channels
// compare CI run numbers rather than patch fields (pf_update_check::version).
let version = std::env::var("PUNKTFUNK_BUILD_VERSION")
.ok()
.filter(|v| !v.trim().is_empty())
.unwrap_or_else(|| std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "unknown".into()));
println!("cargo:rustc-env=PUNKTFUNK_VERSION={version}");
println!("cargo:rerun-if-env-changed=PUNKTFUNK_BUILD_VERSION");
// Host cfg gate mirrors this crate's `#[cfg(target_os = "linux")]` modules: on any other
// host the crate compiles to an empty stub and `glib-compile-resources` may not exist.
#[cfg(target_os = "linux")]
@@ -0,0 +1,2 @@
<!-- bazzite — the Bazzite "b", from ublue-os/bazzite repo_content/Bazzite.svg (Apache-2.0), lifted out of the badge and normalized to a 24x24 box. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M7.178 0h3.589v7.178h7.524c3.153 0 5.709 2.556 5.709 5.709 0 6.138-4.976 11.113-11.113 11.113-3.153 0-5.709-2.556-5.709-5.709V10.766H0v-3.589h7.178zm3.589 10.766v7.524c0 1.171.949 2.12 2.12 2.12 4.156 0 7.524-3.369 7.524-7.524 0-1.171-.949-2.12-2.12-2.12z"/></svg>

After

Width:  |  Height:  |  Size: 518 B

@@ -0,0 +1,2 @@
<!-- cachyos — from Simple Icons (CC0 1.0). See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M5.301 2.646 0 11.771l5.541 9.583h11.486l2.904-5.017H8.102l-2.56-4.429L8.067 7.54h6.063l2.83-4.893ZM20.058 4.12a.748.748 0 0 0 0 1.496.748.748 0 0 0 0-1.496m-1.983 4.303a1.45 1.45 0 0 0 0 2.9 1.45 1.45 0 0 0 0-2.9m4.02 3.98a1.904 1.904 0 0 0 0 3.809 1.904 1.904 0 0 0 0-3.81"/></svg>

After

Width:  |  Height:  |  Size: 433 B

@@ -0,0 +1,2 @@
<!-- nobara — from Simple Icons (CC0 1.0), slug "nobaralinux". See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M23.808 11.808v8.281a3.542 3.542 0 0 1-3.542 3.527h-.46a3.543 3.543 0 0 1-3.083-3.513v-7.282l3.543-1.013-3.66-1.045a4.724 4.724 0 0 0-9.33 1.045v2.362a2.362 2.362 0 0 0 2.362 2.362 3.543 3.543 0 0 1 3.543 3.542V24a3.539 3.539 0 0 0-3.542-3.542 3.537 3.537 0 0 0-3.063 1.76 3.54 3.54 0 0 1-2.382 1.398h-.46A3.542 3.542 0 0 1 .192 20.09V3.543a3.542 3.542 0 0 1 6.323-2.194A11.756 11.756 0 0 1 12 0c6.521 0 11.808 5.287 11.808 11.808zm-9.446 0A2.359 2.359 0 0 1 12 14.17a2.362 2.362 0 1 1 2.362-2.362z"/></svg>

After

Width:  |  Height:  |  Size: 676 B

@@ -1,2 +1,2 @@
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>
<!-- windows — the modern (Windows 11) four-pane mark: four equal squares, no perspective skew. Own geometry, see README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M0 0h11.377v11.377H0zm12.623 0H24v11.377H12.623zM0 12.623h11.377V24H0zm12.623 0H24V24H12.623z"/></svg>

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 318 B

@@ -14,5 +14,8 @@
<file>icons/scalable/actions/pf-os-debian-symbolic.svg</file>
<file>icons/scalable/actions/pf-os-nixos-symbolic.svg</file>
<file>icons/scalable/actions/pf-os-opensuse-symbolic.svg</file>
<file>icons/scalable/actions/pf-os-bazzite-symbolic.svg</file>
<file>icons/scalable/actions/pf-os-cachyos-symbolic.svg</file>
<file>icons/scalable/actions/pf-os-nobara-symbolic.svg</file>
</gresource>
</gresources>
+4 -3
View File
@@ -696,9 +696,10 @@ impl AppModel {
2, // audio_channels: stereo
crate::video::decodable_codecs(), // codecs (unused by the probe, but honest)
0, // preferred_codec: no preference
None, // display_hdr: probe connect, nothing presents
0, // client_caps: probe connect, nothing renders a cursor
None, // launch: probe connect, no game
None, // display_hdr: probe connect, nothing presents
0, // client_caps: probe connect, nothing renders a cursor
false, // frame_parts: probe/whole-AU consumer
None, // launch: probe connect, no game
// Knock under this device's name, not a fingerprint placeholder, when the
// probed host doesn't know us yet.
Some(pf_client_core::trust::device_name()),
+109
View File
@@ -446,6 +446,106 @@ pub fn headless_reset() -> glib::ExitCode {
}
}
/// The version this binary was built as — the CI-stamped string (`0.23.0~ci10250.gab12cd34`
/// and friends) when built by a packaging job, the crate version otherwise. See `build.rs`.
pub fn version_string() -> &'static str {
env!("PUNKTFUNK_VERSION")
}
/// `--version` — one line, so a packaging script's run-the-binary gate and the Decky plugin
/// can both ask "what is installed here?" without a display or a config dir.
fn headless_version() -> glib::ExitCode {
println!("punktfunk-client {}", version_string());
glib::ExitCode::SUCCESS
}
/// `--check-update [--json]` — is a newer client available for this box's channel, and can
/// anything here install it? The answer comes from the same Ed25519-signed manifest the host
/// checks (`pf_client_core::update`); this is only its presentation.
///
/// Exit code carries the verdict for scripts: 0 = up to date, 10 = an update is available,
/// 1 = the check could not be completed (offline, bad signature, disabled). The distinction
/// matters — "could not tell" must never be scripted as "up to date".
fn headless_check_update() -> glib::ExitCode {
let status = pf_client_core::update::check(version_string());
if arg_flag("--json") {
match serde_json::to_string(&status) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("check-update: {e}");
return glib::ExitCode::FAILURE;
}
}
} else {
println!(
"installed {} ({}, {})",
status.current, status.kind, status.channel
);
println!("available {}", status.latest);
if let Some(err) = &status.error {
eprintln!("check-update: {err}");
} else if status.update_available {
println!("update yes");
println!("apply {}", update_apply_line(&status));
} else {
println!("update no");
}
if let Some(hint) = &status.opt_in_hint {
println!("opt-in {hint}");
}
}
if status.error.is_some() {
glib::ExitCode::FAILURE
} else if status.update_available {
// Distinct from both success and failure so `--check-update` is scriptable.
glib::ExitCode::from(10u8)
} else {
glib::ExitCode::SUCCESS
}
}
/// The human line describing how an update would be applied.
fn update_apply_line(status: &pf_client_core::update::Status) -> String {
use pf_client_core::update::Applier;
match status.applier {
Applier::Helper => format!("punktfunk-client --apply-update ({})", status.command),
Applier::Flatpak => status.command.clone(),
Applier::None => status.command.clone(),
}
}
/// `--apply-update [--json]` — drive the packaged root helper for this install. Refuses (with
/// the command to run instead) for every kind it does not own; see
/// `pf_client_core::update::apply`.
fn headless_apply_update() -> glib::ExitCode {
let outcome = pf_client_core::update::apply(version_string());
if arg_flag("--json") {
match serde_json::to_string(&outcome) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("apply-update: {e}");
return glib::ExitCode::FAILURE;
}
}
} else if let Some(err) = &outcome.error {
eprintln!("apply-update: {err}");
} else if outcome.staged {
println!(
"staged {} -> {} (reboot to finish)",
outcome.before, outcome.after
);
} else if outcome.changed {
println!("updated {} -> {}", outcome.before, outcome.after);
} else {
println!("already up to date ({})", outcome.before);
}
if outcome.ok {
glib::ExitCode::SUCCESS
} else {
glib::ExitCode::FAILURE
}
}
/// Dispatch the headless host-store modes (returns `None` when argv names none of them, so the
/// caller proceeds to launch the GTK app). Kept in one place so `arg_flag` stays private and the
/// dispatch in `app.rs` is a single line.
@@ -468,6 +568,15 @@ pub fn headless_host_command() -> Option<glib::ExitCode> {
if arg_flag("--reset") {
return Some(headless_reset());
}
if arg_flag("--version") {
return Some(headless_version());
}
if arg_flag("--check-update") {
return Some(headless_check_update());
}
if arg_flag("--apply-update") {
return Some(headless_apply_update());
}
None
}
+6 -3
View File
@@ -583,10 +583,13 @@ const PROBE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(12);
/// at a glance is the whole reason the chip exists. No colour set keeps the neutral pill, so
/// the palette stays opt-in.
/// The OS-icon tokens this shell ships symbolic art for (`data/icons/.../pf-os-<t>-symbolic.svg`,
/// embedded via gresource). Chains walk most-specific-first, so a distro without its own mark
/// (Bazzite, CachyOS, ...) lands on its family's and finally on plain Tux.
/// embedded via gresource): the families a chain can land on, plus the gaming distros that earn
/// their own mark because "a Bazzite box" and "a Fedora box" are different machines to the person
/// reading the card. Chains walk most-specific-first, so a distro without a mark of its own still
/// lands on its family's and finally on plain Tux.
const OS_ICON_TOKENS: &[&str] = &[
"windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos", "opensuse",
"windows", "apple", "linux", "steam", "ubuntu", "fedora", "arch", "debian", "nixos",
"opensuse", "bazzite", "cachyos", "nobara",
];
/// The card's OS glyph for an advertised chain, or `None` (no widget) when the host doesn't
+5 -26
View File
@@ -41,37 +41,16 @@ anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# The GTK4/libadwaita session shell (app.rs, cli.rs, ui_*.rs, shortcuts.rs, spawn.rs). Those
# modules are `#[cfg(target_os = "linux")]` in main.rs, so their dependencies belong in a
# linux-only block — the Windows leg of this crate is a stub binary and must not pull GTK.
#
# Versions and features track clients/linux verbatim (gtk4 0.11 "v4_16", libadwaita 0.9 "v1_5"):
# the two crates compile the same widget vocabulary, and a version skew between them would show up
# as type mismatches through relm4 rather than as anything legible.
#
# glib / gio / gdk / pango are NOT listed on purpose — the sources reach them as `gtk::glib`,
# `gtk::gio`, `gtk::gdk` and `gtk::pango`, gtk4's own re-exports. Declaring them separately would
# risk resolving a DIFFERENT version of the same sys crate than the one gtk4 links against.
[target.'cfg(target_os = "linux")'.dependencies]
gtk = { package = "gtk4", version = "0.11", features = ["v4_16"] }
adw = { package = "libadwaita", version = "0.9", features = ["v1_5"] }
relm4 = { version = "0.11", features = ["libadwaita"] }
async-channel = "2"
# NOT optional here, unlike the shared block above where it hangs off `ui`. cli.rs's `--json` host
# listing is a CLI feature, not a console-UI one, so on Linux it is needed whether or not `ui` is
# on — and `--no-default-features` is a documented Linux build ("same streaming, stats on stdout
# only"), which without this simply did not compile.
serde_json = "1"
# This crate carries NO toolkit, deliberately: it is the renderer the shells spawn, and the
# `--no-default-features` build is what a minimal/embedded image installs. GTK4/libadwaita/relm4
# belong to `clients/linux` (the shell) and must never appear here — a stray copy of the shell's
# modules landed in src/ once (b0ea1e6b) and dragging its dependencies in behind it is what let
# the wrong `main.rs` build and ship as `punktfunk-session`.
# Embeds the app icon as an exe resource (build.rs) — Windows hosts only (rc.exe from
# the SDK); same pattern as clients/windows.
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"
# Compiles data/ (the OS-mark symbolic icons) into the embedded gresource (build.rs) —
# needs `glib-compile-resources`, which ships with the GTK dev stack this crate needs anyway.
[target.'cfg(target_os = "linux")'.build-dependencies]
glib-build-tools = "0.22"
[lints]
workspace = true
+10 -7
View File
@@ -5,10 +5,15 @@ no widgets, terminal stats. The power-user / gamescope stream client, and the st
presenter of the Linux client re-architecture (punktfunk-planning:
`linux-client-rearchitecture.md`).
This binary is deliberately dumb: a renderer the front-ends call INTO — the GTK shell
(`punktfunk-client`), the WinUI shell, and the `punktfunk` CLI all spawn it through the
same brain (`pf_client_core::orchestrate`), which resolves policy (profiles, settings,
wake) and hands the result down, normally as a `--resolved-spec` file. It reads the
shared stores only as the compat fallback for a bare hand-launched invocation.
```
punktfunk-session --connect host[:port] [--fp HEX] [--launch id] [--fullscreen] [--stats]
punktfunk-session --browse host[:port] [--mgmt PORT] [--fullscreen]
punktfunk-session --pair <PIN> --connect host[:port] [--name LABEL]
```
`--browse` opens the console game library (the Skia coverflow over the animated aurora)
@@ -21,12 +26,10 @@ Reads the same identity / known-hosts / settings stores as the desktop client
(`punktfunk-client`), so enrolling on either side makes the other work; this binary never
connects to a host it has no pinned fingerprint for (`--fp HEX` overrides the store).
`--pair <PIN> --connect host[:port]` runs the SPAKE2 ceremony with no window and no
toolkit, prints `paired <addr>:<port> fp=<hex>`, and exits — the route for a machine that
has only SSH (an embedded/kiosk client, an image being provisioned). `--name` sets the
label the host files this client under, defaulting to the hostname. It is in the
`--no-default-features` build too: enrolling must never be the reason a minimal image has
to pull in Skia.
Pairing is `punktfunk pair <host>` — the CLI, which ships alongside this binary in every
package and needs no window and no toolkit either. `punktfunk-session --pair` still works
for one release (someone's provisioning script calls it today) but prints a deprecation
notice: pairing is a trust ceremony and belongs to the brain, not a renderer.
Stdout is the machine interface: `{"ready":true}` after the first presented frame,
`stats: …` once per second while the overlay tier isn't Off (always the full detailed
+2 -11
View File
@@ -4,17 +4,8 @@
//! icon is the generic default).
fn main() {
// Linux: compile the shell's embedded assets (`data/` — the host-card OS-mark symbolic
// icons) into a gresource bundle, registered at startup via
// `gio::resources_register_include!`. Host-gated like the Windows leg below.
#[cfg(target_os = "linux")]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("linux") {
glib_build_tools::compile_resources(
&["data"],
"data/resources.gresource.xml",
"punktfunk-client.gresource",
);
}
// No Linux leg: this binary carries no toolkit, so it has no gresource to compile. The
// OS-mark icons belong to the GTK shell (`clients/linux/data`), which builds its own.
// cfg(windows) is the HOST (skips Linux/macOS builds of this cross-platform binary);
// CARGO_CFG_WINDOWS is the TARGET (x64 and cross-compiled ARM64 both pass).
@@ -1,2 +0,0 @@
<!-- apple — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaApple. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512" fill="#000000"><path d="M318.7 268.7c-.2-36.7 16.4-64.4 50-84.8-18.8-26.9-47.2-41.7-84.7-44.6-35.5-2.8-74.3 20.7-88.5 20.7-15 0-49.4-19.7-76.4-19.7C63.3 141.2 4 184.8 4 273.5q0 39.3 14.4 81.2c12.8 36.7 59 126.7 107.2 125.2 25.2-.6 43-17.9 75.8-17.9 31.8 0 48.3 17.9 76.4 17.9 48.6-.7 90.4-82.5 102.6-119.3-65.2-30.7-61.7-90-61.7-91.9zm-56.6-164.2c27.3-32.4 24.8-61.9 24-72.5-24.1 1.4-52 16.4-67.9 34.9-17.5 19.8-27.8 44.3-25.6 71.9 26.1 2 49.9-11.4 69.5-34.3z"/></svg>

Before

Width:  |  Height:  |  Size: 635 B

@@ -1,2 +0,0 @@
<!-- arch — from Simple Icons (CC0 1.0), via react-icons SiArchlinux. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M11.39.605C10.376 3.092 9.764 4.72 8.635 7.132c.693.734 1.543 1.589 2.923 2.554-1.484-.61-2.496-1.224-3.252-1.86C6.86 10.842 4.596 15.138 0 23.395c3.612-2.085 6.412-3.37 9.021-3.862a6.61 6.61 0 01-.171-1.547l.003-.115c.058-2.315 1.261-4.095 2.687-3.973 1.426.12 2.534 2.096 2.478 4.409a6.52 6.52 0 01-.146 1.243c2.58.505 5.352 1.787 8.914 3.844-.702-1.293-1.33-2.459-1.929-3.57-.943-.73-1.926-1.682-3.933-2.713 1.38.359 2.367.772 3.137 1.234-6.09-11.334-6.582-12.84-8.67-17.74zM22.898 21.36v-.623h-.234v-.084h.562v.084h-.234v.623h.331v-.707h.142l.167.5.034.107a2.26 2.26 0 01.038-.114l.17-.493H24v.707h-.091v-.593l-.206.593h-.084l-.205-.602v.602h-.091"/></svg>

Before

Width:  |  Height:  |  Size: 836 B

@@ -1,2 +0,0 @@
<!-- debian — from Simple Icons (CC0 1.0), via react-icons SiDebian. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M13.88 12.685c-.4 0 .08.2.601.28.14-.1.27-.22.39-.33a3.001 3.001 0 01-.99.05m2.14-.53c.23-.33.4-.69.47-1.06-.06.27-.2.5-.33.73-.75.47-.07-.27 0-.56-.8 1.01-.11.6-.14.89m.781-2.05c.05-.721-.14-.501-.2-.221.07.04.13.5.2.22M12.38.31c.2.04.45.07.42.12.23-.05.28-.1-.43-.12m.43.12l-.15.03.14-.01V.43m6.633 9.944c.02.64-.2.95-.38 1.5l-.35.181c-.28.54.03.35-.17.78-.44.39-1.34 1.22-1.62 1.301-.201 0 .14-.25.19-.34-.591.4-.481.6-1.371.85l-.03-.06c-2.221 1.04-5.303-1.02-5.253-3.842-.03.17-.07.13-.12.2a3.551 3.552 0 012.001-3.501 3.361 3.362 0 013.732.48 3.341 3.342 0 00-2.721-1.3c-1.18.01-2.281.76-2.651 1.57-.6.38-.67 1.47-.93 1.661-.361 2.601.66 3.722 2.38 5.042.27.19.08.21.12.35a4.702 4.702 0 01-1.53-1.16c.23.33.47.66.8.91-.55-.18-1.27-1.3-1.48-1.35.93 1.66 3.78 2.921 5.261 2.3a6.203 6.203 0 01-2.33-.28c-.33-.16-.77-.51-.7-.57a5.802 5.803 0 005.902-.84c.44-.35.93-.94 1.07-.95-.2.32.04.16-.12.44.44-.72-.2-.3.46-1.24l.24.33c-.09-.6.74-1.321.66-2.262.19-.3.2.3 0 .97.29-.74.08-.85.15-1.46.08.2.18.42.23.63-.18-.7.2-1.2.28-1.6-.09-.05-.28.3-.32-.53 0-.37.1-.2.14-.28-.08-.05-.26-.32-.38-.861.08-.13.22.33.34.34-.08-.42-.2-.75-.2-1.08-.34-.68-.12.1-.4-.3-.34-1.091.3-.25.34-.74.54.77.84 1.96.981 2.46-.1-.6-.28-1.2-.49-1.76.16.07-.26-1.241.21-.37A7.823 7.824 0 0017.702 1.6c.18.17.42.39.33.42-.75-.45-.62-.48-.73-.67-.61-.25-.65.02-1.06 0C15.082.73 14.862.8 13.8.4l.05.23c-.77-.25-.9.1-1.73 0-.05-.04.27-.14.53-.18-.741.1-.701-.14-1.431.03.17-.13.36-.21.55-.32-.6.04-1.44.35-1.18.07C9.6.68 7.847 1.3 6.867 2.22L6.838 2c-.45.54-1.96 1.611-2.08 2.311l-.131.03c-.23.4-.38.85-.57 1.261-.3.52-.45.2-.4.28-.6 1.22-.9 2.251-1.16 3.102.18.27 0 1.65.07 2.76-.3 5.463 3.84 10.776 8.363 12.006.67.23 1.65.23 2.49.25-.99-.28-1.12-.15-2.08-.49-.7-.32-.85-.7-1.34-1.13l.2.35c-.971-.34-.57-.42-1.361-.67l.21-.27c-.31-.03-.83-.53-.97-.81l-.34.01c-.41-.501-.63-.871-.61-1.161l-.111.2c-.13-.21-1.52-1.901-.8-1.511-.13-.12-.31-.2-.5-.55l.14-.17c-.35-.44-.64-1.02-.62-1.2.2.24.32.3.45.33-.88-2.172-.93-.12-1.601-2.202l.15-.02c-.1-.16-.18-.34-.26-.51l.06-.6c-.63-.74-.18-3.102-.09-4.402.07-.54.53-1.1.88-1.981l-.21-.04c.4-.71 2.341-2.872 3.241-2.761.43-.55-.09 0-.18-.14.96-.991 1.26-.7 1.901-.88.7-.401-.6.16-.27-.151 1.2-.3.85-.7 2.421-.85.16.1-.39.14-.52.26 1-.49 3.151-.37 4.562.27 1.63.77 3.461 3.011 3.531 5.132l.08.02c-.04.85.13 1.821-.17 2.711l.2-.42M9.54 13.236l-.05.28c.26.35.47.73.8 1.01-.24-.47-.42-.66-.75-1.3m.62-.02c-.14-.15-.22-.34-.31-.52.08.32.26.6.43.88l-.12-.36m10.945-2.382l-.07.15c-.1.76-.34 1.511-.69 2.212.4-.73.65-1.541.75-2.362M12.45.12c.27-.1.66-.05.95-.12-.37.03-.74.05-1.1.1l.15.02M3.006 5.142c.07.57-.43.8.11.42.3-.66-.11-.18-.1-.42m-.64 2.661c.12-.39.15-.62.2-.84-.35.44-.17.53-.2.83"/></svg>

Before

Width:  |  Height:  |  Size: 2.8 KiB

@@ -1,2 +0,0 @@
<!-- fedora — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaFedora. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M225 32C101.3 31.7.8 131.7.4 255.4L0 425.7a53.6 53.6 0 0 0 53.6 53.9l170.2.4c123.7.3 224.3-99.7 224.6-223.4S348.7 32.3 225 32zm169.8 157.2L333 126.6c2.3-4.7 3.8-9.2 3.8-14.3v-1.6l55.2 56.1a101 101 0 0 1 2.8 22.4zM331 94.3a106.06 106.06 0 0 1 58.5 63.8l-54.3-54.6a26.48 26.48 0 0 0-4.2-9.2zM118.1 247.2a49.66 49.66 0 0 0-7.7 11.4l-8.5-8.5a85.78 85.78 0 0 1 16.2-2.9zM97 251.4l11.8 11.9-.9 8a34.74 34.74 0 0 0 2.4 12.5l-27-27.2a80.6 80.6 0 0 1 13.7-5.2zm-18.2 7.4l38.2 38.4a53.17 53.17 0 0 0-14.1 4.7L67.6 266a107 107 0 0 1 11.2-7.2zm-15.2 9.8l35.3 35.5a67.25 67.25 0 0 0-10.5 8.5L53.5 278a64.33 64.33 0 0 1 10.1-9.4zm-13.3 12.3l34.9 35a56.84 56.84 0 0 0-7.7 11.4l-35.8-35.9c2.8-3.8 5.7-7.2 8.6-10.5zm-11 14.3l36.4 36.6a48.29 48.29 0 0 0-3.6 15.2l-39.5-39.8a99.81 99.81 0 0 1 6.7-12zm-8.8 16.3l41.3 41.8a63.47 63.47 0 0 0 6.7 26.2L25.8 326c1.4-4.9 2.9-9.6 4.7-14.5zm-7.9 43l61.9 62.2a31.24 31.24 0 0 0-3.6 14.3v1.1l-55.4-55.7a88.27 88.27 0 0 1-2.9-21.9zm5.3 30.7l54.3 54.6a28.44 28.44 0 0 0 4.2 9.2 106.32 106.32 0 0 1-58.5-63.8zm-5.3-37a80.69 80.69 0 0 1 2.1-17l72.2 72.5a37.59 37.59 0 0 0-9.9 8.7zm253.3-51.8l-42.6-.1-.1 56c-.2 69.3-64.4 115.8-125.7 102.9-5.7 0-19.9-8.7-19.9-24.2a24.89 24.89 0 0 1 24.5-24.6c6.3 0 6.3 1.6 15.7 1.6a55.91 55.91 0 0 0 56.1-55.9l.1-47c0-4.5-4.5-9-8.9-9l-33.6-.1c-32.6-.1-32.5-49.4.1-49.3l42.6.1.1-56a105.18 105.18 0 0 1 105.6-105 86.35 86.35 0 0 1 20.2 2.3c11.2 1.8 19.9 11.9 19.9 24 0 15.5-14.9 27.8-30.3 23.9-27.4-5.9-65.9 14.4-66 54.9l-.1 47a8.94 8.94 0 0 0 8.9 9l33.6.1c32.5.2 32.4 49.5-.2 49.4zm23.5-.3a35.58 35.58 0 0 0 7.6-11.4l8.5 8.5a102 102 0 0 1-16.1 2.9zm21-4.2L308.6 280l.9-8.1a34.74 34.74 0 0 0-2.4-12.5l27 27.2a74.89 74.89 0 0 1-13.7 5.3zm18-7.4l-38-38.4c4.9-1.1 9.6-2.4 13.7-4.7l36.2 35.9c-3.8 2.5-7.9 5-11.9 7.2zm15.5-9.8l-35.3-35.5a61.06 61.06 0 0 0 10.5-8.5l34.9 35a124.56 124.56 0 0 1-10.1 9zm13.2-12.3l-34.9-35a63.18 63.18 0 0 0 7.7-11.4l35.8 35.9a130.28 130.28 0 0 1-8.6 10.5zm11-14.3l-36.4-36.6a48.29 48.29 0 0 0 3.6-15.2l39.5 39.8a87.72 87.72 0 0 1-6.7 12zm13.5-30.9a140.63 140.63 0 0 1-4.7 14.3L345.6 190a58.19 58.19 0 0 0-7.1-26.2zm1-5.6l-71.9-72.1a32 32 0 0 0 9.9-9.2l64.3 64.7a90.93 90.93 0 0 1-2.3 16.6z"/></svg>

Before

Width:  |  Height:  |  Size: 2.3 KiB

@@ -1,2 +0,0 @@
<!-- linux — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaLinux. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M220.8 123.3c1 .5 1.8 1.7 3 1.7 1.1 0 2.8-.4 2.9-1.5.2-1.4-1.9-2.3-3.2-2.9-1.7-.7-3.9-1-5.5-.1-.4.2-.8.7-.6 1.1.3 1.3 2.3 1.1 3.4 1.7zm-21.9 1.7c1.2 0 2-1.2 3-1.7 1.1-.6 3.1-.4 3.5-1.6.2-.4-.2-.9-.6-1.1-1.6-.9-3.8-.6-5.5.1-1.3.6-3.4 1.5-3.2 2.9.1 1 1.8 1.5 2.8 1.4zM420 403.8c-3.6-4-5.3-11.6-7.2-19.7-1.8-8.1-3.9-16.8-10.5-22.4-1.3-1.1-2.6-2.1-4-2.9-1.3-.8-2.7-1.5-4.1-2 9.2-27.3 5.6-54.5-3.7-79.1-11.4-30.1-31.3-56.4-46.5-74.4-17.1-21.5-33.7-41.9-33.4-72C311.1 85.4 315.7.1 234.8 0 132.4-.2 158 103.4 156.9 135.2c-1.7 23.4-6.4 41.8-22.5 64.7-18.9 22.5-45.5 58.8-58.1 96.7-6 17.9-8.8 36.1-6.2 53.3-6.5 5.8-11.4 14.7-16.6 20.2-4.2 4.3-10.3 5.9-17 8.3s-14 6-18.5 14.5c-2.1 3.9-2.8 8.1-2.8 12.4 0 3.9.6 7.9 1.2 11.8 1.2 8.1 2.5 15.7.8 20.8-5.2 14.4-5.9 24.4-2.2 31.7 3.8 7.3 11.4 10.5 20.1 12.3 17.3 3.6 40.8 2.7 59.3 12.5 19.8 10.4 39.9 14.1 55.9 10.4 11.6-2.6 21.1-9.6 25.9-20.2 12.5-.1 26.3-5.4 48.3-6.6 14.9-1.2 33.6 5.3 55.1 4.1.6 2.3 1.4 4.6 2.5 6.7v.1c8.3 16.7 23.8 24.3 40.3 23 16.6-1.3 34.1-11 48.3-27.9 13.6-16.4 36-23.2 50.9-32.2 7.4-4.5 13.4-10.1 13.9-18.3.4-8.2-4.4-17.3-15.5-29.7zM223.7 87.3c9.8-22.2 34.2-21.8 44-.4 6.5 14.2 3.6 30.9-4.3 40.4-1.6-.8-5.9-2.6-12.6-4.9 1.1-1.2 3.1-2.7 3.9-4.6 4.8-11.8-.2-27-9.1-27.3-7.3-.5-13.9 10.8-11.8 23-4.1-2-9.4-3.5-13-4.4-1-6.9-.3-14.6 2.9-21.8zM183 75.8c10.1 0 20.8 14.2 19.1 33.5-3.5 1-7.1 2.5-10.2 4.6 1.2-8.9-3.3-20.1-9.6-19.6-8.4.7-9.8 21.2-1.8 28.1 1 .8 1.9-.2-5.9 5.5-15.6-14.6-10.5-52.1 8.4-52.1zm-13.6 60.7c6.2-4.6 13.6-10 14.1-10.5 4.7-4.4 13.5-14.2 27.9-14.2 7.1 0 15.6 2.3 25.9 8.9 6.3 4.1 11.3 4.4 22.6 9.3 8.4 3.5 13.7 9.7 10.5 18.2-2.6 7.1-11 14.4-22.7 18.1-11.1 3.6-19.8 16-38.2 14.9-3.9-.2-7-1-9.6-2.1-8-3.5-12.2-10.4-20-15-8.6-4.8-13.2-10.4-14.7-15.3-1.4-4.9 0-9 4.2-12.3zm3.3 334c-2.7 35.1-43.9 34.4-75.3 18-29.9-15.8-68.6-6.5-76.5-21.9-2.4-4.7-2.4-12.7 2.6-26.4v-.2c2.4-7.6.6-16-.6-23.9-1.2-7.8-1.8-15 .9-20 3.5-6.7 8.5-9.1 14.8-11.3 10.3-3.7 11.8-3.4 19.6-9.9 5.5-5.7 9.5-12.9 14.3-18 5.1-5.5 10-8.1 17.7-6.9 8.1 1.2 15.1 6.8 21.9 16l19.6 35.6c9.5 19.9 43.1 48.4 41 68.9zm-1.4-25.9c-4.1-6.6-9.6-13.6-14.4-19.6 7.1 0 14.2-2.2 16.7-8.9 2.3-6.2 0-14.9-7.4-24.9-13.5-18.2-38.3-32.5-38.3-32.5-13.5-8.4-21.1-18.7-24.6-29.9s-3-23.3-.3-35.2c5.2-22.9 18.6-45.2 27.2-59.2 2.3-1.7.8 3.2-8.7 20.8-8.5 16.1-24.4 53.3-2.6 82.4.6-20.7 5.5-41.8 13.8-61.5 12-27.4 37.3-74.9 39.3-112.7 1.1.8 4.6 3.2 6.2 4.1 4.6 2.7 8.1 6.7 12.6 10.3 12.4 10 28.5 9.2 42.4 1.2 6.2-3.5 11.2-7.5 15.9-9 9.9-3.1 17.8-8.6 22.3-15 7.7 30.4 25.7 74.3 37.2 95.7 6.1 11.4 18.3 35.5 23.6 64.6 3.3-.1 7 .4 10.9 1.4 13.8-35.7-11.7-74.2-23.3-84.9-4.7-4.6-4.9-6.6-2.6-6.5 12.6 11.2 29.2 33.7 35.2 59 2.8 11.6 3.3 23.7.4 35.7 16.4 6.8 35.9 17.9 30.7 34.8-2.2-.1-3.2 0-4.2 0 3.2-10.1-3.9-17.6-22.8-26.1-19.6-8.6-36-8.6-38.3 12.5-12.1 4.2-18.3 14.7-21.4 27.3-2.8 11.2-3.6 24.7-4.4 39.9-.5 7.7-3.6 18-6.8 29-32.1 22.9-76.7 32.9-114.3 7.2zm257.4-11.5c-.9 16.8-41.2 19.9-63.2 46.5-13.2 15.7-29.4 24.4-43.6 25.5s-26.5-4.8-33.7-19.3c-4.7-11.1-2.4-23.1 1.1-36.3 3.7-14.2 9.2-28.8 9.9-40.6.8-15.2 1.7-28.5 4.2-38.7 2.6-10.3 6.6-17.2 13.7-21.1.3-.2.7-.3 1-.5.8 13.2 7.3 26.6 18.8 29.5 12.6 3.3 30.7-7.5 38.4-16.3 9-.3 15.7-.9 22.6 5.1 9.9 8.5 7.1 30.3 17.1 41.6 10.6 11.6 14 19.5 13.7 24.6zM173.3 148.7c2 1.9 4.7 4.5 8 7.1 6.6 5.2 15.8 10.6 27.3 10.6 11.6 0 22.5-5.9 31.8-10.8 4.9-2.6 10.9-7 14.8-10.4s5.9-6.3 3.1-6.6-2.6 2.6-6 5.1c-4.4 3.2-9.7 7.4-13.9 9.8-7.4 4.2-19.5 10.2-29.9 10.2s-18.7-4.8-24.9-9.7c-3.1-2.5-5.7-5-7.7-6.9-1.5-1.4-1.9-4.6-4.3-4.9-1.4-.1-1.8 3.7 1.7 6.5z"/></svg>

Before

Width:  |  Height:  |  Size: 3.6 KiB

@@ -1,2 +0,0 @@
<!-- nixos — from Simple Icons (CC0 1.0), via react-icons SiNixos. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#000000"><path d="M7.352 1.592l-1.364.002L5.32 2.75l1.557 2.713-3.137-.008-1.32 2.34H14.11l-1.353-2.332-3.192-.006-2.214-3.865zm6.175 0l-2.687.025 5.846 10.127 1.341-2.34-1.59-2.765 2.24-3.85-.683-1.182h-1.336l-1.57 2.705-1.56-2.72zm6.887 4.195l-5.846 10.125 2.696-.008 1.601-2.76 4.453.016.682-1.183-.666-1.157-3.13-.008L21.778 8.1l-1.365-2.313zM9.432 8.086l-2.696.008-1.601 2.76-4.453-.016L0 12.02l.666 1.157 3.13.008-1.575 2.71 1.365 2.315L9.432 8.086zM7.33 12.25l-.006.01-.002-.004-1.342 2.34 1.59 2.765-2.24 3.85.684 1.182H7.35l.004-.006h.001l1.567-2.698 1.558 2.72 2.688-.026-.004-.006h.01L7.33 12.25zm2.55 3.93l1.354 2.332 3.192.006 2.215 3.865 1.363-.002.668-1.156-1.557-2.713 3.137.008 1.32-2.34H9.881Z"/></svg>

Before

Width:  |  Height:  |  Size: 875 B

@@ -1,2 +0,0 @@
<!-- opensuse — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSuse. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512" fill="#000000"><path d="M471.08 102.66s-.3 18.3-.3 20.3c-9.1-3-74.4-24.1-135.7-26.3-51.9-1.8-122.8-4.3-223 57.3-19.4 12.4-73.9 46.1-99.6 109.7C7 277-.12 307 7 335.06a111 111 0 0 0 16.5 35.7c17.4 25 46.6 41.6 78.1 44.4 44.4 3.9 78.1-16 90-53.3 8.2-25.8 0-63.6-31.5-82.9-25.6-15.7-53.3-12.1-69.2-1.6-13.9 9.2-21.8 23.5-21.6 39.2.3 27.8 24.3 42.6 41.5 42.6a49 49 0 0 0 15.8-2.7c6.5-1.8 13.3-6.5 13.3-14.9 0-12.1-11.6-14.8-16.8-13.9-2.9.5-4.5 2-11.8 2.4-2-.2-12-3.1-12-14V316c.2-12.3 13.2-18 25.5-16.9 32.3 2.8 47.7 40.7 28.5 65.7-18.3 23.7-76.6 23.2-99.7-20.4-26-49.2 12.7-111.2 87-98.4 33.2 5.7 83.6 35.5 102.4 104.3h45.9c-5.7-17.6-8.9-68.3 42.7-68.3 56.7 0 63.9 39.9 79.8 68.3H460c-12.8-18.3-21.7-38.7-18.9-55.8 5.6-33.8 39.7-18.4 82.4-17.4 66.5.4 102.1-27 103.1-28 3.7-3.1 6.5-15.8 7-17.7 1.3-5.1-3.2-2.4-3.2-2.4-8.7 5.2-30.5 15.2-50.9 15.6-25.3.5-76.2-25.4-81.6-28.2-.3-.4.1 1.2-11-25.5 88.4 58.3 118.3 40.5 145.2 21.7.8-.6 4.3-2.9 3.6-5.7-13.8-48.1-22.4-62.7-34.5-69.6-37-21.6-125-34.7-129.2-35.3.1-.1-.9-.3-.9.7zm60.4 72.8a37.54 37.54 0 0 1 38.9-36.3c33.4 1.2 48.8 42.3 24.4 65.2-24.2 22.7-64.4 4.6-63.3-28.9zm38.6-25.3a26.27 26.27 0 1 0 25.4 27.2 26.19 26.19 0 0 0-25.4-27.2zm4.3 28.8c-15.4 0-15.4-15.6 0-15.6s15.4 15.64 0 15.64z"/></svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

@@ -1,2 +0,0 @@
<!-- steam — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaSteam. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M496 256c0 137-111.2 248-248.4 248-113.8 0-209.6-76.3-239-180.4l95.2 39.3c6.4 32.1 34.9 56.4 68.9 56.4 39.2 0 71.9-32.4 70.2-73.5l84.5-60.2c52.1 1.3 95.8-40.9 95.8-93.5 0-51.6-42-93.5-93.7-93.5s-93.7 42-93.7 93.5v1.2L176.6 279c-15.5-.9-30.7 3.4-43.5 12.1L0 236.1C10.2 108.4 117.1 8 247.6 8 384.8 8 496 119 496 256zM155.7 384.3l-30.5-12.6a52.79 52.79 0 0 0 27.2 25.8c26.9 11.2 57.8-1.6 69-28.4 5.4-13 5.5-27.3.1-40.3-5.4-13-15.5-23.2-28.5-28.6-12.9-5.4-26.7-5.2-38.9-.6l31.5 13c19.8 8.2 29.2 30.9 20.9 50.7-8.3 19.9-31 29.2-50.8 21zm173.8-129.9c-34.4 0-62.4-28-62.4-62.3s28-62.3 62.4-62.3 62.4 28 62.4 62.3-27.9 62.3-62.4 62.3zm.1-15.6c25.9 0 46.9-21 46.9-46.8 0-25.9-21-46.8-46.9-46.8s-46.9 21-46.9 46.8c.1 25.8 21.1 46.8 46.9 46.8z"/></svg>

Before

Width:  |  Height:  |  Size: 932 B

@@ -1,2 +0,0 @@
<!-- ubuntu — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaUbuntu. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 496 512" fill="#000000"><path d="M248 8C111 8 0 119 0 256s111 248 248 248 248-111 248-248S385 8 248 8zm52.7 93c8.8-15.2 28.3-20.5 43.5-11.7 15.3 8.8 20.5 28.3 11.7 43.6-8.8 15.2-28.3 20.5-43.5 11.7-15.3-8.9-20.5-28.4-11.7-43.6zM87.4 287.9c-17.6 0-31.9-14.3-31.9-31.9 0-17.6 14.3-31.9 31.9-31.9 17.6 0 31.9 14.3 31.9 31.9 0 17.6-14.3 31.9-31.9 31.9zm28.1 3.1c22.3-17.9 22.4-51.9 0-69.9 8.6-32.8 29.1-60.7 56.5-79.1l23.7 39.6c-51.5 36.3-51.5 112.5 0 148.8L172 370c-27.4-18.3-47.8-46.3-56.5-79zm228.7 131.7c-15.3 8.8-34.7 3.6-43.5-11.7-8.8-15.3-3.6-34.8 11.7-43.6 15.2-8.8 34.7-3.6 43.5 11.7 8.8 15.3 3.6 34.8-11.7 43.6zm.3-69.5c-26.7-10.3-56.1 6.6-60.5 35-5.2 1.4-48.9 14.3-96.7-9.4l22.5-40.3c57 26.5 123.4-11.7 128.9-74.4l46.1.7c-2.3 34.5-17.3 65.5-40.3 88.4zm-5.9-105.3c-5.4-62-71.3-101.2-128.9-74.4l-22.5-40.3c47.9-23.7 91.5-10.8 96.7-9.4 4.4 28.3 33.8 45.3 60.5 35 23.1 22.9 38 53.9 40.2 88.5l-46 .6z"/></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

@@ -1,2 +0,0 @@
<!-- windows — from Font Awesome Free 5 brands (CC BY 4.0), via react-icons FaWindows. See README.md. -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" fill="#000000"><path d="M0 93.7l183.6-25.3v177.4H0V93.7zm0 324.6l183.6 25.3V268.4H0v149.9zm203.8 28L448 480V268.4H203.8v177.9zm0-380.6v180.1H448V32L203.8 65.7z"/></svg>

Before

Width:  |  Height:  |  Size: 339 B

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