6b3e793b394ef3e1c53a4eaaa78e4732d95da4b7
304
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
f0b35de92a |
feat(screenshots): the library shelf carries real cover art on every platform
apple / swift (pull_request) Successful in 2m5s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m15s
ci / web (pull_request) Successful in 1m34s
ci / docs-site (pull_request) Successful in 1m27s
ci / bun-nix (pull_request) Successful in 39s
android / android (pull_request) Successful in 7m21s
ci / rust (pull_request) Successful in 7m50s
The Apple scenes passed artLoader nil, so the store's library frames shipped empty frosted cards next to Android's populated shelf. LibraryArtSource (a two-method protocol the production LibraryArtLoader already satisfies) lets the harness answer art from a canned source, and the four posters are drawn procedurally at capture time — CoreGraphics on Apple, Canvas on Android, same designs, same seeds — replacing Android's gradient-plus-monogram tiles. Nothing is bundled; release builds ship none of it. |
||
|
|
1e5dca4c25 |
Merge pull request 'A pad whose Select is KEYCODE_BACK quit the session on one press' (#235) from worktree-shield-select-back-quit into main
Reviewed-on: #235 |
||
|
|
1ac6c9bf3d |
fix(android): a pad whose Select is KEYCODE_BACK quit the session on one press
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m23s
ci / rust-arm64 (pull_request) Successful in 2m50s
ci / web (pull_request) Successful in 5m55s
ci / rust (pull_request) Canceled after 6m47s
android / android (pull_request) Successful in 5m38s
Field report: pressing Select disconnected the stream. The host log was
unambiguous about what it was NOT — "client datagram stream ended" plus "virtual
display torn down (deliberate quit — keep-alive skipped)" is a client that said
it was leaving, not a drop and not a compositor crash.
Only two client paths raise that: StreamScreen's BackHandler, and the exit chord
(router.onExitChord). The chord is excluded by construction — `armExit` posts a
1 s timer and releasing any member calls `disarmExit`, so a tap always cancels.
That leaves the back stack, and from a SOURCE_GAMEPAD device KEYCODE_BACK is the
ONLY keycode that reaches it: a mapped button is consumed in the gamepad branch,
anything with a VK is consumed on the keycode path, volume/power go to the
system, and a FLAG_FALLBACK BACK is swallowed. So a one-press quit identifies the
button's keycode without knowing which controller was on the couch.
Plenty of pads deliver Select as the plain KEYCODE_BACK a remote's Back uses,
with no BUTTON_SELECT scancode behind it — the Android-TV shape, where every
input device is expected to offer Back, reached whether the vendor prints "Back"
on the button or "Select"/"View". `buttonBit` had no row for KEYCODE_BACK, so the
press fell through unconsumed into StreamScreen's BackHandler, which is the
deliberate-quit exit. One press, session over.
The same gap meant those pads could not produce BTN_BACK at all, so every
shortcut built on Select was unreachable on exactly the devices whose users have
no keyboard: the emergency exit chord StreamScreen's own start banner advertises
("Hold Select + Start + L1 + R1 to leave"), the mic mute, the stats tier.
New `Gamepad.padButtonBit(keyCode, flags)` — buttonBit plus that one row —
resolves a gamepad-sourced BACK to BTN_BACK, and MainActivity's streaming branch
asks it instead. It keys off the keycode, not the vendor, so it covers every pad
with this behaviour; a pad that does carry BUTTON_SELECT is unaffected in both
directions, having never had the bug. FLAG_FALLBACK events stay excluded: those
are the synthetic BACK the framework raises after an unconsumed BUTTON_* press,
and forwarding one would put a phantom Select on the wire (one landing while
Start + L1 + R1 were held would complete the exit chord out of nowhere). A
remote's or keyboard's BACK is neither mouse- nor gamepad-sourced, so it still
leaves the stream — for a device with no pad on it that is the documented way
out, and the banner says so.
The mouse-side-button hook moves above the gamepad branch so a device that can
be a mouse keeps its X1/X2 semantics; it answers null for everything that cannot
be a mouse, so nothing else changes route.
PadButtonBitTest pins the mapping, the fallback exclusion, that the three Select
chords are now reachable from a BACK-only pad, and that no other keycode moved.
Verified: :kit:testDebugUnitTest + :app:testDebugUnitTest green (PadButtonBitTest
4/4), :app:compileDebugKotlin clean. NOT yet verified on-glass — the behaviour
needs a real pad: Select reaches the game, and Back no longer quits.
|
||
|
|
36e133ae66 |
Merge pull request 'Android canary ships to Play open + closed testing instead of internal' (#234) from worktree-android-canary-open-testing into main
ci / web (push) Successful in 1m30s
ci / bun-nix (push) Successful in 17s
ci / rust-arm64 (push) Failing after 2m32s
ci / docs-site (push) Successful in 1m45s
android / android (push) Successful in 7m59s
ci / rust (push) Canceled after 17m30s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 11s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 12s
docker / builders (ci/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (push) Successful in 12s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 1m20s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 52s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 27s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 11s
docker / builders-arm64cross (push) Successful in 9s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 48s
docker / deploy-docs (push) Successful in 36s
Reviewed-on: #234 |
||
|
|
d7e66fafe1 |
Merge pull request 'The store's four marketing frames become screenshot scenes on both platforms' (#233) from worktree-store-marketing-scenes into main
android / android (push) Canceled after 17s
ci / rust (push) Canceled after 0s
ci / rust-arm64 (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / docs-site (push) Canceled after 0s
ci / bun-nix (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/gamescope-trixie.Dockerfile, punktfunk-gamescope-trixie) (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 1m59s
apple / distribute (push) Successful in 11m3s
apple / screenshots (push) Canceled after 7m31s
Reviewed-on: #233 |
||
|
|
a4210024dc |
ci(android): canary also feeds Play closed testing (alpha)
android / android (pull_request) Successful in 7m7s
ci / rust (pull_request) Successful in 7m38s
ci / web (pull_request) Successful in 2m25s
ci / docs-site (pull_request) Successful in 4m18s
ci / bun-nix (pull_request) Successful in 2m40s
ci / rust-arm64 (pull_request) Successful in 2m23s
Closed testing went dry on 2026-08-01 when tags started publishing straight to production instead of alpha — its testers have been pinned to the last pre-access build since. Canaries now assign the same versionCode to beta (open) AND alpha (closed) via play-upload.py's new repeatable --also-track flag: both PUTs share one Play edit, so one commit and one review cover both tracks and they can never disagree about which canary is current. Tags still go to production only. |
||
|
|
ed935ed31c |
ci(android): canary ships to Play open testing (beta), not internal
Main-push canaries now land on the open-testing track: public opt-in link, no tester-list cap. Trade-off documented in the workflow header: open testing goes through Google review (hours/days), where internal was review-free (minutes). android-promote's from_track default follows the canary to beta. |
||
|
|
026dbe6153 |
fix(screenshots): captures grow the status bar Robolectric never had
ci / rust-arm64 (pull_request) Successful in 1m57s
ci / bun-nix (pull_request) Successful in 48s
ci / docs-site (pull_request) Successful in 1m55s
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 8m58s
android / android (pull_request) Successful in 6m12s
ci / rust (pull_request) Successful in 27m10s
Robolectric renders no system UI and zero insets, so every phone capture was missing the status bar and its content sat where the bar belongs — on the Pixel store render the app title collided with the camera punch-hole. ShotStatusFrame draws a plausible bar (time left, radios right, the CENTRE left empty for the hole) and pushes the scene below it, the same geometry real insets produce; height mirrors a Pixel's tall bar measured off a real capture. On for the touch screens, off for the immersive surfaces (stream, console shell, TV) that hide the real bar too. |
||
|
|
3cc8fa7ee0 |
feat(core): the host tells the client where its library is, so mDNS is no longer required
ci / rust (pull_request) Failing after 10m40s
ci / rust-arm64 (pull_request) Failing after 31s
android / android (pull_request) Failing after 1m9s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 2m4s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Failing after 1m47s
ci / bun-nix (pull_request) Successful in 2m23s
ci / web (pull_request) Successful in 5m45s
nix / flake (pull_request) Successful in 13m46s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Failing after 1m47s
ABI 19 -> 20. Wire protocol unchanged (still 2). Persisting the mgmt port (fe2bfeca) made a moved port survive mDNS going away, but mDNS was still the only SOURCE: a host that had never been seen on it — VPN-only, a routed subnet, or simply added by address on a network where multicast has never worked — had nothing to learn from and fell back to 47990. The `Welcome` now carries the port, so the client learns it over the connection it has already authenticated and discovery stops being involved at all. `Welcome.mgmt_port`, a trailing u16 after the cipher block, following the same additive discipline as the eight fields before it (compositor, gamepad, bitrate_kbps, bit_depth, color, chroma_format, audio_channels, codec): an older peer stops earlier and gets a documented default, in both directions, so WIRE_VERSION does not move. ⚠ THE TRAP, and why emitting the port forces the `cipher` placeholder: `cipher` is emitted only when non-default, so appending the port to an AES Welcome would land its LOW BYTE at offset 68 — exactly where every shipped 0.28.x client reads `cipher`, whose decode is deliberately fail-closed on an unknown id. 47991 is 0xBB57, so byte 68 would read 0x57 = 87, and EVERY current client would fail the handshake against a host that had merely moved its mgmt port. `encode` therefore writes an explicit cipher byte whenever a port rides along (the placeholder discipline `Hello::encode` already uses); a current client reads AES, a pre-cipher client stops before 68 regardless. The test pins the byte, both offsets (69 AES / 101 ChaCha), and that a host advertising no port still emits exactly 68 bytes — this field costs the common case nothing. Host: `mgmt::effective_port()` reads the same resolved bind `publish_endpoint` writes, so the wire, the endpoint file and the mDNS TXT cannot disagree — one lookup, not a fourth place to compute a port. `0` on the standalone punktfunk1-host binary, which has no management API: advertising 47990 from a host that is not serving it would be worse than saying nothing. Clients persist it on connect, feeding the store plumbing fe2bfeca already built: * Rust — `on_connected` grew the port alongside the fingerprint, plus `learn_mgmt_port_by_fp` (keyed by fingerprint alone, the identity a just-connected client is certain of). * Apple — `PunktfunkConnection.hostMgmtPort` + `updateMgmtPort` at the existing markConnected site. * Android — a new `nativeHostMgmtPort` JNI call, persisted where the session is constructed. Verified: Linux (pf-lxcheck2, amd64) `cargo check --all-targets` clean across punktfunk-core, pf-host-config, punktfunk-host, pf-client-core, pf-presenter, punktfunk-cli, punktfunk-client-linux and punktfunk-client-session, each confirmed genuinely compiled (counting `Compiling` as well as `Checking` — cargo prints the former for bin-only packages, which is what made an earlier gate look vacuous when it was not). punktfunk-core quic tests 76/76. Android: :kit+:app Kotlin, ParseRecordTest 12/12, and cargoNdkClippy clean for aarch64-linux-android. Apple: xcframework rebuilt at ABI 20, `swift build` complete. cargo fmt --all --check clean. NOT verified: the Windows client (192.168.1.133 unreachable). |
||
|
|
99eb679c07 |
feat(clients): a moved mgmt port now outlives the advert that announced it
Moving the mgmt port off 47990 (the fix for sharing a box with a Sunshine fork, whose web UI owns
that port) only ever worked for as long as mDNS did. The real port lived in the advert and nowhere
else: every client read it live and threw it away, so on a VPN, a routed subnet, or any
multicast-dead network the library silently fell back to a port nothing was listening on.
`KnownHost` gains `mgmt_port: Option<u16>` + `effective_mgmt_port()` + `learn_mgmt_port()`, exactly
the shape `mac` and `os` already use ("learned from the advert while online, persisted so it
survives the host going to sleep") — except this one is load-bearing rather than cosmetic, so
`upsert` states the preserve rule explicitly instead of relying on the does-not-mention-it accident
that `clipboard_sync` survives by, and `upsert_trusted` carries it across a re-key.
Wired through all four client families, each of which was wrong in its own way:
* CLI / Windows / Linux reached for `DEFAULT_MGMT_PORT` at the call site — the constant is the
FALLBACK, not the answer. Windows also needed the port on `Target`, which the library screen has
instead of a `KnownHost`.
* The session console read `advert.and_then(mgmt_port)` with NO saved fallback, two lines above an
`os` that gets the three-rung treatment right. It now matches, and learns on every tick.
* Linux's `mgmt_port_for` consulted live adverts only; it now falls back to the store.
* Android never carried the port at all — its native discovery record stopped at 8 fields. Added
`mgmt` as the 9th (the record's own documented "new fields append, never reorder" rule), then
through `DiscoveredHost` -> `KnownHost` -> `LibraryScreen`.
* Apple LOOKED done and was not: `StoredHost.mgmtPort` and `effectiveMgmtPort` have existed all
along, but nothing anywhere wrote the field and the `mgmt` TXT was never parsed — so it was
permanently nil and every Apple client resolved to 47990 regardless. That is worse than the
honest omissions above, because it reads as finished. Now parsed, carried on `DiscoveredHost`,
and written by `HostStore.updateMgmtPort` at the same site that learns MACs and the OS chain.
Also `PUNKTFUNK_NATIVE_PORT` in host.env, finishing the pair with PUNKTFUNK_MGMT_BIND: `--native-port`
was likewise CLI-only and died on a package upgrade. A bad value is a startup ERROR rather than the
silent fall back to 9777 that `PUNKTFUNK_DATA_PORT` still does — the failure that reads as "I moved
the port and the client still can't reach me". The client side of the native port already worked
(`KnownHost.port` is persisted, `--connect HOST:PORT` names it).
Adding the field broke three `KnownHost` literals in tests, which is the `Default` impl's stated
purpose working ("adding a field here can't silently produce records that lack it"). All three now
carry 47991 — deliberately NOT the default, so the assertions cannot pass vacuously against a
hardcode. New coverage: forward-compat decode of a store predating the field, the resolver
fallback, re-key carry-forward, and on Android the 9th-field parse plus 0/non-numeric/out-of-range
all reading as unknown.
What this does NOT fix: a host that moved its mgmt port and has NEVER been seen over mDNS. Nothing
tells the client where to look, and the honest fix is for the host to announce it in-band — the
`Welcome` message has an established "append a trailing field, older peer decodes to the default"
pattern for exactly this, at the cost of a C ABI accessor and a bump. Left for a separate change.
Verified: Linux (punktfunk-rust-ci/pf-lxcheck2, amd64) `cargo check --all-targets` clean for
pf-host-config, punktfunk-host, pf-client-core, punktfunk-cli, punktfunk-client-linux and
punktfunk-client-session — the last confirmed non-vacuous by planting a compile_error! and watching
the gate fail (cargo prints "Compiling", not "Checking", for bin-only packages, so the usual marker
grep lies about it). Android: :kit + :app compileDebugKotlin clean, ParseRecordTest 12/12 with both
new cases named in the XML. Apple: xcframework built, `swift build` complete, SharedFoundationTests
pass. cargo fmt --all --check clean. NOT verified: the Windows client (192.168.1.133 unreachable).
|
||
|
|
cc70c64797 |
feat(screenshots): the add-host sheet becomes a scene — the blends' phone screens were three designs old
The Blender store scenes render whatever screens/ holds, and theirs were June captures of the pre-console UI. Fresh captures existed for hosts and pair but the add-host sheet had no scene: AddHostSheet's state is hoisted (ConnectScreen keeps half-typed values across dismissal), so the scene passes a filled form straight in. Two capture-truth fixes with it: dialog scenes advance the frozen clock 1.6 s (a ModalBottomSheet's entrance spring is still mid-rise at 0.8 s), and the add-host shot uses Pixel-like geometry (411×915dp @ 420 dpi — same 1080×2400 px, but the dp headroom is what lets the Connect button, the row carrying the resolution promise, fit in frame). |
||
|
|
e3443da108 |
fix(screenshots): the store frames go landscape — the app is built for horizontal use
Portrait captures show a layout nobody streams in. The touch controllers frame and the library shot now render at landscape phone geometry (the portrait library variant is gone), a console-controllers-landscape frame joins the set, and the Apple 12-controllers scene rotates: on the landscape canvas the two pads sit as side-by-side columns — one ControllerTestView per pad — so neither story is cut by the short height. Known wart, deliberate: the console landscape frame's floating legend overlaps the second pad card mid-scroll; the styled composite crops above it, and the touch variant carries the uncropped two-card view. |
||
|
|
0a468c96da |
feat(screenshots): the two missing marketing frames — the library shelf and pads that exist
The store plan's PICK & PLAY and FEEL THE GAME shots had no scene on any platform: the library screen's state comes off the network, and the controllers screens enumerate InputDevices, of which Robolectric has none (the old shot honestly said 'no controller detected' — a palette proof that sells nothing). - Android library: Coverflow goes internal and LibraryScene rebuilds the real shell around it (aurora, header, hint bar) with a mock shelf. Cover art is answered synchronously by coil-test's FakeImageLoaderEngine with generated gradient posters, so the frozen animation clock never races an async load. Shot at phone portrait+landscape and TV geometry. - Android controllers: PadRow renders a PadInfo model instead of a raw InputDevice (padInfoOf maps real devices; both screens take a padsOverride). The scenes inject the two pads the listing names — DualSense (player 1) and Xbox (player 2), real VID:PIDs. - Apple library: ShotLibrary composes the real LibraryCoverflowView with a JSON-decoded mock shelf (GameEntry's memberwise init is internal to PunktfunkKit; Codable is the public construction surface), registered as cross-platform scene 11-library and added to the store set + the tvOS CI scene list. Artless entries settle to their deterministic fallback posters, which is also what keeps the shot offline. Apple controllers stays a follow-up: ControllerTestView binds to live GCController hardware and has no injection surface yet. Verified: all 31 Roborazzi scenes render; the new tv-library, phone-library and controllers shots reviewed by eye. |
||
|
|
b6b3c10cb5 |
ci(screenshots): the TV storefronts were the only ones with no automated captures
ci / bun-nix (pull_request) Successful in 1m49s
ci / rust-arm64 (pull_request) Successful in 4m41s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m53s
apple / swift (pull_request) Successful in 2m3s
ci / docs-site (pull_request) Successful in 5m11s
android / android (pull_request) Successful in 11m24s
ci / rust (pull_request) Failing after 10m14s
Google Play's Android TV slot needs 16:9 1920x1080 shots and the App Store needs Apple TV 1920x1080 — neither existed as automation output: - apple.yml screenshots job now runs the tvos leg. The harness supported it all along (tools/screenshots.sh tvos); what the job was missing is the Tier-3 tvOS xcframework slices (nightly + -Zbuild-std, same recipe the distribute job uses on this runner) and an explicit scene list — the gamepad-console scenes are compiled out on tvOS, and an UNKNOWN scene name falls back to a normal app launch, which would silently capture the real empty app. Still best-effort: a tvOS hiccup warns, never reds. - TvScreenshotTest renders the console scenes + the stream HUD at Android TV geometry (w960dp-h540dp-television-xhdpi = native 1920x1080, no resampling), prefixed tv- so the artifact separates the form factors. Verified locally: 6 scenes, all 1920x1080. android-screenshots.yml needs no change — it runs the whole unit-test task and uploads the whole roborazzi output dir. |
||
|
|
38631d43df |
fix(client/android): the audio plane trusted AAudio, so a TV that opened a dead stream was silent all session
ci / bun-nix (pull_request) Successful in 19s
ci / web (pull_request) Successful in 1m13s
android / android (pull_request) Canceled after 1m41s
ci / rust (pull_request) Canceled after 1m39s
ci / docs-site (pull_request) Canceled after 1m41s
ci / rust-arm64 (pull_request) Canceled after 1m43s
Field report: no audio at all on an NVIDIA Shield Android TV, stereo, same
host and settings that play fine on an Apple TV. Video unaffected. Turning
off low-latency mode — which gates the forced HDMI mode switch and the
usage=Game tagging, the two things that toggle controls — changed nothing.
This client opens AAudio directly, where the Apple one goes through
AVAudioEngine and gets route-change handling for free; that is why this was
Android-only. Opening AAudio is a negotiation with a vendor HAL and this
plane treated it as a formality: one Exclusive attempt, one Shared retry,
everything after the open taken on trust. Three separate failures all came
out as "the app has no sound" behind a perfectly ordinary log line:
- a configuration that opens but routes nowhere — nothing ever checked
that the device pulled a single sample, so the decode thread fed Opus
into a dead stream indefinitely;
- request_start failing — we gave up on the spot rather than trying
anything else, so one unhappy config disabled audio for the session;
- a disconnect — by AAudio's contract the stream is then DEAD and the
only recovery is close + open a new one, but the error callback logged
a warning and did nothing. On a TV that is not rare: this client drives
an HDMI mode switch on the video plane, and the platform's own
match-content-frame-rate setting drives more.
The open now walks a ladder, every rung must prove the device is pulling
before it is accepted, and a supervisor owns the plane for the session and
reopens it when the device goes away — with bounded retries across the
settling time of a route change, so a reopen landing mid-switch cannot
permanently disable audio. Granted rate/channels/format are checked rather
than assumed: the realtime callback casts AAudio's buffer to f32 and writes
num_frames * channels of them, so a HAL that disagreed was an out-of-bounds
write on the audio thread, not just a mistuning.
TV boxes now start at Shared. Exclusive is MMAP, the lowest-latency path
AAudio has and the one rung whose routing cannot be verified from inside
the process; the latency it buys was never banked, since the ring depths
are unchanged from the Shared-only era (AAUDIO still primes at 25 ms). On a
mains-powered HDMI box that trade is not worth betting the audio plane on.
Phones keep Exclusive first. If no rung proves itself the first one that
opened and started is used anyway — a watchdog must never be able to turn
working audio into no audio.
nativeStartAudio takes isTv (FEATURE_LEANBACK, the source the video plane
already used) because ro.build.characteristics is not answered by every TV.
debug.punktfunk.audio_sharing / audio_perf / audio_reopen bisect all of it
with setprop, for the device that reports silence and cannot be handed a
custom build. A stream that stops taking samples after it started now says
so at error level instead of looking exactly like an app with no sound.
Not verified on a Shield — no such device here.
|
||
|
|
e7af5a5274 |
feat(android): drop the in-stream mic control from the stream overlay
ci / rust-arm64 (pull_request) Successful in 2m2s
ci / docs-site (pull_request) Successful in 1m53s
ci / bun-nix (pull_request) Successful in 24s
ci / web (pull_request) Successful in 7m24s
ci / rust (pull_request) Successful in 15m22s
android / android (pull_request) Failing after 1m3s
The mic element sat in the top-right corner of every stream that opened a capture — a standing button on touch, a Muted badge on TV. It goes for now; the on-screen overlay UI being built will carry mute as one of its controls, and re-introducing it there is the right moment to decide how it looks. Mute itself is untouched: `micRunning`, `micMuted` and `setMicMuted` still back the Select + Y chord, which is now the whole of the control, and `MicChordHint` is now its only on-screen feedback (its doc updated to say so rather than pointing at the badge that no longer exists). |
||
|
|
a4af1ee8bd |
chore(deps): regenerate third-party notices for the currency wave
Covers all five generated files, not just the root one: the four per-client copies are scoped to the binaries their package installs, so they move independently of the workspace-wide file. Root: 571 -> 575 crates, reflecting this wave (skia-safe 0.99, the RustCrypto digest-0.11 family, jni 0.22, x11rb 0.14, reis 0.7, xkbcommon 0.9, wasapi 0.24, windows-service 0.8.1, x509-parser 0.18, rand 0.9, base64 0.23, libloading 0.9, mdns-sd 0.21 + if-addrs 0.15, rcgen 0.14, criterion 0.8, android_logger 0.15). The per-client diffs are much larger than the wave alone explains, because they were never regenerated after #192: all four still attributed `ring` and named no aws-lc-rs at all. Since #192 removed ring from the tree entirely, the shipped Acknowledgements screens have been crediting a crypto library the clients do not carry while omitting the one they do. They now catch up on both changes at once. (`ring` still appears via the generator's deliberate `--all-features` over-approximation, which sees quinn-proto's wasm-only edge; that is by design — listing an unlinked crate is untidy, omitting a linked one is the failure the file exists to prevent.) Also stops gen-third-party-notices.sh preferring `cargo about` for the root file. That preference was silently destructive: cargo-about only sees CARGO dependencies, so it drops every VENDORED_TREES entry -- pyrowave, the Granite subset, volk, Vulkan-Headers, the Font Awesome brand icons, Simple Icons -- which are third-party sources shipped inside first-party crates under their own licences. Measured today: cargo-about emitted 7,274 lines / ~514 crates with zero mentions of volk, Vulkan-Headers or Font Awesome, against the python generator's 17,324 / 575 with all of them. Merely having cargo-about on PATH was enough to degrade the file, so anyone regenerating after this commit would have undone it. cargo-about remains what the CI licence gate runs -- that job asks a different question (is every licence in the about.toml allowlist) and writes to /dev/null. Both licence-gate legs pass: `cargo about generate about.hbs --fail` and the drivers-workspace leg, RC=0. |
||
|
|
5cd4da4b46 |
Merge branch 'worktree-agent-a3a9cb8eda476b361' into worktree-dep-currency-wave
# Conflicts: # Cargo.lock # crates/punktfunk-host/Cargo.toml |
||
|
|
2718b7d4bd |
chore(deps): mdns-sd 0.20 -> 0.21 and if-addrs 0.13 -> 0.15, together
One commit because splitting them accomplishes nothing: mdns-sd 0.20 ALREADY
depends on if-addrs 0.15, so while our own five crates declared 0.13 the tree
carried both copies no matter which of the two moved first. Moving them together
is what collapses it:
$ cargo tree -d | grep '^if-addrs'
(no output)
Neither needed a source change. mdns-sd 0.21's public API is purely additive
over 0.20.3 — the sole new item is `ServiceDaemon::set_max_packet_size`, and
`ServiceInfo`'s surface is byte-identical — so `ServiceDaemon`/`ServiceInfo`/
`ServiceEvent`/`ResolvedService` behave as before at all six call sites
(host discovery + gamestream mdns, pf-client-core, and the Android, Windows and
probe clients). if-addrs 0.15 keeps 0.13's `Interface`/`IfAddr` shape, and we
only ever read those.
The one real change is a FEATURE, not a version. if-addrs has `link-local`, and
mdns-sd declares if-addrs with it on. Once our crates share that single copy,
unification turns it on for our calls too — meaning `get_if_addrs()` now also
reports fe80:: interfaces (and, on Windows, 169.254.x.x). Rather than inherit
that silently, punktfunk-core and punktfunk-host now NAME the feature. Two
reasons: it is what every real build gets anyway, so a standalone `cargo test -p
punktfunk-core` should not enumerate a different set of NICs than the host does;
and for the consumer here — Wake-on-LAN — it is the behaviour we want, since a
NIC is wake-capable whether or not it currently holds a routable address.
Verified on CachyOS (rustc 1.96.0):
cargo clippy --workspace --all-targets --locked -- -D warnings OK
cargo test --workspace --locked OK, 0 failed
cargo test -p punktfunk-host --bins --locked 501 passed, 0 failed, 2 ignored
cargo test ... gamestream::cert 3 passed
cargo fmt --all --check clean
(One `cargo test --workspace` attempt failed with E0463 "can't find crate for
pf_frame" in a doc-test. That is the target dir having only clippy's .rmeta for
a crate a doc-test wants to LINK, not anything in this change; a plain re-run
after cargo test built the rlibs was green.)
|
||
|
|
d73bdcdcc1 |
chore(deps): jni 0.21 → 0.22 for the Android JNI bridge
Version currency, but a real API migration rather than a version-number edit — and the
payoff is bigger than "one crate is newer", because jni 0.22 was ALREADY in this .so:
`rustls-platform-verifier` (via quinn-proto, for Android cert verification) depends on it,
so pinning 0.21 here compiled two complete jni copies into one library. Matching the
version collapses them.
Eliminated, measured as the delta in THIRD-PARTY-NOTICES.txt (582 → 571 crates, nothing
added): jni 0.21.1, its `cesu8`, and — because jni 0.21 was the SOLE consumer of
windows-sys 0.45.0, the oldest crate in the tree — that whole windows-rs 0.42 generation:
windows-sys 0.45.0, windows-targets 0.42.2 and its seven per-arch import libraries. Eleven
crates, carried for a `cfg(windows)` dependency of an Android-only bridge.
NOT eliminated, contrary to what the sweep expected — recorded in the manifest so the next
person does not re-derive it. thiserror 1.0 and the jni-sys 0.3/0.4 split both survive,
because jni was never their only source:
thiserror 1.0.69 ← vendor/ndk 0.9.0 (+ asn1-rs/x509-parser, host side)
jni-sys 0.3.1 ← vendor/ndk 0.9.0 AND crates.io ndk-sys 0.6
jni's share of both is gone; the remainder is the ndk stack. jni-sys 0.3.1 is itself a
facade crate over 0.4.1, so the split cannot close until ndk and ndk-sys move, and `ndk` is
vendored for a one-line visibility patch — changing its dependency versions would mean
rewriting the vendored snapshot instead of reading it as a diff against upstream. Left
alone deliberately.
The migration itself, across 66 native methods in 10 files:
* `JNIEnv` split into `EnvUnowned` (the FFI-safe native-method argument) and `Env` (where
the JNI calls live). The 41 methods that never touched the environment are a type
rename; the 22 that do now acquire an `Env` inside `EnvUnowned::with_env` and map the
outcome with an `ErrorPolicy`.
* `LogErrorAndDefault` everywhere, chosen to PRESERVE behaviour: the old code swallowed
JNI errors and returned a default, and this logs and returns the same default. The
throwing policy would have been a behaviour change (new Java exceptions out of methods
that previously failed quietly), which is not what a currency bump should do.
* `with_env` also catches panics, which is exactly what the crate's own `jni_guard` did,
so the guard is folded into it where the two would have nested. It stays on the methods
that take no `Env`. ONE exception, marked at the call site: `nativeNextHidout` returns
-1 as its failure sentinel, and every error policy resolves to `T::default()` — which
for `jint` is 0, a *valid* byte count. That method keeps `jni_guard(-1, …)` outside and
uses `with_env_no_catch` inside so the sentinel survives the panic path unchanged.
* `jboolean` is `bool` in jni-sys 0.4, not `u8` — a type change, not a rename, and the
reason for most of the mechanical diff (`down != 0` → `down`, `return 0` → `false`).
* `Env::get_string` is deprecated in favour of `JString::try_to_string`, and CI runs
clippy with `-D warnings`, so the call sites moved rather than being left to warn.
Likewise `set_/get_*_array_region` → `JPrimitiveArray::set_region`/`get_region`.
* `Env::get_native_interface()` is now `Env::get_raw()` — the raw pointer handed to
`ndk::NativeWindow::from_surface`. The `as *mut _` cast next to it was already commented
as bridging jni-sys skew between `jni` and `ndk`; that skew is now real (0.4 vs 0.3)
rather than hypothetical, so the comment says so.
* Return types moved from raw `jni::sys::jstring`/`jdoubleArray`/`jintArray` to
`JString`/`JDoubleArray`/`JIntArray`, because `resolve()` requires `T: Default` and raw
pointers have no `Default`. All three are `#[repr(transparent)]` over the same
`jobject`, so the exported ABI is unchanged and `Default` IS the null reference the old
code returned explicitly.
Kotlin and Gradle needed NO changes, checked rather than assumed: every affected
`external fun` in NativeBridge.kt already declares `Boolean` / `String?` / `IntArray?` /
`DoubleArray?`, which is what these signatures still present to the JVM, and the Gradle
side only shells out to cargo-ndk without naming a jni version.
Verified on NDK 30.0.14904198, both shipping ABIs, using the environment
clients/android/kit/build.gradle.kts hands cargo-ndk:
cargo clippy -p punktfunk-client-android --all-targets -- -D warnings → ok (host)
cargo test -p punktfunk-client-android → 20 passed
cargo ndk -t arm64-v8a --platform 28 clippy … -- -D warnings → ok
cargo ndk -t armeabi-v7a --platform 28 clippy … -- -D warnings → ok
cargo ndk -t arm64-v8a -t armeabi-v7a --platform 28 build → both .so LINKED
scripts/ci/check-android-jni-imports.sh … 28 → 2 ABI(s) clean at the API-28 floor
That last one matters for this change specifically: a cdylib links with dangling undefined
symbols, so the floor check is the only thing that would catch jni 0.22 hard-importing an
NDK entry point above minSdk 28 — the shape of the 0.9.0 `System.loadLibrary` regression.
It is clean.
`cargo tree -i ring` stays empty on host and on aarch64-linux-android.
|
||
|
|
00245499e0 |
chore(deps): android_logger 0.14 → 0.15
Version currency for the Android client's only `log` backend. No code change: 0.15 is almost entirely an internal refactor (the single `lib.rs` split into `config`/`arrays`/ `id`/`platform_log_writer`/`tests`), and the surface this crate uses — `init_once`, `Config::default`, `with_max_level`, `with_tag` — is untouched. The lockfile delta is the version and checksum alone: no dependency was added, removed or re-resolved, and the third-party crate count stays at 582. The one thing 0.15 adds that we must NOT take is recorded next to the dependency: the new opt-in `android-api-30` feature filters levels through `__android_log_is_loggable_len` so logcat's `setprop log.tag.*` overrides are honoured, but it HARD-LINKS that API-30 symbol. Against our minSdk-28 floor that is a `System.loadLibrary` failure on Android 9/10 — the identical shape of the ndk 0.9.0 `AMediaCodec_setOnFrameRenderedCallback` break the manifest already warns about a few lines further down. Default features keep it off; the comment explains why so nobody "completes" the upgrade by enabling it. Verified with cargo-ndk (NDK 30.0.14904198), which is the only way to exercise this crate at all — `android_logger` sits behind `cfg(target_os = "android")`, so the host workspace build never compiles it: cargo ndk -t arm64-v8a check -p punktfunk-client-android → ok cargo ndk -t armeabi-v7a check -p punktfunk-client-android → ok Checked, not built: these are `cargo check` runs, not a linked `.so` and not an APK. |
||
|
|
79d755cd98 |
feat(crypto): move the rustls backend from ring to aws-lc-rs, with PQ key exchange
The workspace pinned `ring` everywhere because aws-lc-sys 0.41.0 failed to C-compile on the Windows CI runner. Re-tested on that runner (.133) with aws-lc-sys 0.44.0: the `params.c` cl.exe failure does not reproduce under MSVC 14.44, and rustls's `aws_lc_rs` feature turns on `aws-lc-rs/prebuilt-nasm`, so no NASM is needed on the box either. That unblocks post-quantum TLS: `prefer-post-quantum` offers X25519MLKEM768 first on every TLS 1.3 handshake (mgmt API, native control plane, QUIC), which ring cannot do — it has no ML-KEM. Classical curves stay in the list, so older clients still connect. rustls, quinn, rcgen and tokio-rustls each select a backend independently, so all four had to move together; a single dissenter pulls a second crypto stack in via feature unification. The direct `ring` users (ed25519 in pf-update-check, SHA-256 in the Windows updater) moved to aws-lc-rs, whose API is ring-compatible. `ring` does NOT leave the tree: ureq 2 names `features = ["ring"]` in its own rustls dependency line and cargo features are additive, so no dependent can switch it off. Two backends compiled in means rustls refuses to infer one, and anything built via `ClientConfig::builder()` panics instead of picking — which is what ureq's default agent does on its first HTTPS request. `tls::install_default_provider()` makes the choice explicit; it runs at each binary's entry point and defensively in pf-client-core, which several binaries link. Dropping ring entirely needs the ureq 2 -> 3 upgrade (36 call sites), deliberately left out of this change. Verified on macOS: pf-update-check 32, punktfunk-core 385, c_abi 1 (the last with LIBRARY_PATH=/opt/homebrew/opt/opus/lib) — aws-lc-sys links into the C ABI harness, so the Swift/Kotlin embedders keep working. cargo fmt --all --check clean. |
||
|
|
c68e0be688 |
Merge remote-tracking branch 'origin/main' into worktree-edition-2024
ci / bun-nix (pull_request) Successful in 24s
windows-drivers / probe-and-proto (pull_request) Successful in 34s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m45s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 3m4s
windows-drivers / driver-build (pull_request) Successful in 2m23s
ci / rust (pull_request) Failing after 4m6s
ci / rust-arm64 (pull_request) Failing after 5m5s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 1m54s
nix / flake (pull_request) Successful in 13m36s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 54s
android / android (pull_request) Successful in 15m6s
|
||
|
|
f373dffb5e |
chore: migrate the main workspace and pf-vkhdr-layer to edition 2024 (WP20)
The safety half of the rust-safety programme's §8.4: `std::env::set_var`/`remove_var` are
`unsafe fn` in edition 2024, converting the class of bug the programme found the hard way
(the
|
||
|
|
2a62fe7857 |
fix(client): stop the double-arm race re-freezing RFI-healed streams
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m22s
ci / rust-arm64 (pull_request) Successful in 1m42s
ci / web (pull_request) Successful in 1m51s
apple / swift (pull_request) Successful in 1m50s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
android / android (pull_request) Successful in 6m39s
windows / build (x86_64-pc-windows-msvc) (pull_request) Failing after 13m7s
ci / rust (pull_request) Successful in 14m4s
Every unrecoverable loss armed the client's freeze gate twice: instantly at frame-index-gap detection (which fires the RFI), and ~120 ms later when the reassembler ages the lost frame into frames_dropped and poll() re-armed unconditionally. An LTR-RFI recovery anchor lands in ~60 ms — between the two signals — so the stale climb re-froze a bit-exact-healed stream, the host swallowed the re-ask as an RFI echo, and the picture stayed frozen until the overdue backstop extracted a full IDR: the field 'H265 freezes on every loss, AV1 fine' signature on AMD hosts (AMF is the only LTR-RFI backend; the slower IDR path usually lands after the climb and dodged the race). The gap-arm now pre-credits the expected climb (ReanchorGate::arm_expecting_drops; credit expires after DROP_CREDIT_WINDOW so a straggler-filled gap can't mask a later real loss), and poll() consumes credited climbs instead of re-arming. Plumbed through every embedder: pf-client-core's session pump, Android's sync/async loops (note_frame_index now returns the gap width), and the Swift client via new ABI exports punktfunk_connection_note_frame_index_ex + punktfunk_reanchor_gate_arm_expecting_drops (additive; the bool ABI stays). |
||
|
|
2bfd1cd2d5 |
chore(safety): three unsafe-hygiene grep gates, blocking in ci.yml (WP2c gates)
scripts/ci/check-unsafe-hygiene.sh — textual gates for three classes no lint covers: A. unsafe fn markers carrying no contract. unsafe_op_in_unsafe_fn forces real ops into blocks, so an unsafe fn with no `unsafe` in its body is a marker with no contract ( |
||
|
|
cc8eb7df08 |
feat(clients): every pinned card gets a library, and it launches with that card's profile
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m4s
ci / docs-site (pull_request) Successful in 1m13s
apple / swift (pull_request) Successful in 1m44s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 3m10s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 4m39s
android / android (pull_request) Successful in 7m12s
ci / rust (pull_request) Successful in 7m58s
windows / build (aarch64-pc-windows-msvc) (pull_request) Failing after 11m18s
The console fix before this one closed the leak on one client. The same question
has a different wrong answer on each of the others, so this closes it everywhere:
a pinned host+profile card can be browsed, and every title launched off a pinned
card's shelf streams with that card's profile.
Two shapes of bug, one per client:
**The library was not offered on a pinned card at all** — Apple (both UIs:
`hasLibrary: profile == nil` on the console tile, no menu item on the touch card),
Android (`hasLibrary` required `pinnedProfileId == null`, and the console host
options gated Library on `pin == null`), GTK (the pinned card's menu had Connect,
Copy link, Create shortcut, Unpin — no library) and Windows (pinned tiles had no
menu whatsoever). Each was justified in a comment as "a pin is a shortcut, not a
second host, so the host-level actions stay on the host's tile" — right about
wake, pair, edit and forget, wrong about the library. Browsing is not a property
of the machine: it is this card's connect with a title picked first, which is
exactly what a shortcut is for. So the library joins Connect on every pinned
surface, and the host-level actions stay where they were.
**The launch dropped the profile.** GTK already carried it (its library clones
the card's whole ConnectRequest) and Windows already carried it (its library page
launches through the shared target, which the tile parks). Apple did not: the
library was keyed on a bare `StoredHost`, so `launchTitle` connected with
`.inherit` and the host's binding won even from a pinned card. Android was worse
still, and not only for pins — `LibraryScreen` dialled `connectToHost` with the
RAW settings, so a library launch applied NO profile at all, not even the host's
binding, on every host. Its host list resolves
`settings.effectiveFor(profileStore.resolveFor(kh, oneOff))`; its library resolved
nothing.
So the shelf, not the host, is now the unit both clients navigate by. Apple gets
a `LibraryTarget` (host + `ProfileSelection`) threaded through `libraryTarget`,
the shell's screen enum, both presentations and `SessionModel`; Android passes
the pinned id into `LibraryScreen` and resolves it there through the same
`ProfileStore.resolveFor` rule the connect path uses. Falling out of that:
* a game that exits returns to the shelf it was launched from, pin and all,
rather than to the host's default one — `SessionModel.launchedShelf` on Apple,
`ActiveSession.libraryProfileId` → `LibraryReturn` on Android. Android also
drops a pin that was unpinned while the game ran, rather than reopening a card
that no longer exists.
* Android's stats overlay finally names the profile a library launch used
(`profileName` was never set on that path).
* Apple's `punktfunk://browse/<host>` honours `profile=`, which it parsed and
ignored — and refuses an unknown or ambiguous reference exactly as the connect
route does (§10.6) rather than degrading to the binding.
* every shelf says which one it is, in the card's own `host · profile` shape:
the console's title, GTK's page title, Android's ConsoleHeader, Apple's
navigation title and its console heading.
Verified per platform, none of it on trust:
* console + GTK: fmt, build, clippy `-D warnings` and 85 tests green in the
pf-lxcheck2 container (a Mac `cargo test -p pf-console-ui` compiles nothing).
* Apple: `swift build` green for macOS, iOS and tvOS. Worth all three — the tvOS
pass alone caught `navigationDestination(item:)` needing Hashable, and an
iOS-only screen was confirmed genuinely compiled by planting a type error.
* Android: `compileDebugKotlin` + unit tests, with HomeTilesTest's pin
expectation flipped to match.
* Windows: `cargo check --all-targets` + clippy `-D warnings` on the CI runner,
cold (3m10s) — that client cannot compile on a Mac.
|
||
|
|
332a81b077 |
Merge remote-tracking branch 'origin/main' into worktree-android-console-visual-refresh
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 6m15s
android / android (pull_request) Successful in 6m58s
ci / web (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust (pull_request) Successful in 15m56s
nix / flake (pull_request) Successful in 14m8s
|
||
|
|
f692a1877c |
fix(android): the console answers its first field test — the glass, the drum, and the room to breathe
Every item from the on-glass review on the Nothing Phone 3, which found the refresh technically
correct and visually wrong.
**The glass was structurally broken, twice.** The focus bloom was drawn OUTSIDE the clip on
purpose ("a glow that stops at the edge is just a brighter border") — but unclipped drawing does
not stop at the row's neighbours either: in a list it painted over the rows above and below, and in
the carousel it escaped the card entirely. And the focus drop shadow is drawn UNDER the surface,
which is translucent — so the shadow showed straight through the fill as a dark rectangle floating
inside every card and field. Both are gone, and the comment forbids their return: the Apple glass
(`GlassStyle.swift`) is material + an animatable tint, full stop. Focus is now the fill and border
brightening (tint up to 0.28, near Apple's 0.30) — which is also why it finally animates like the
Apple client: one interpolating fill instead of four stacked effects arriving on separate curves.
**The select field is now the Apple drum.** `ConsoleOptionBand` ports `GamepadOptionBand.swift`
whole: options ride a turning cylinder segment, position driven by one spring whose retargeting
preserves velocity — rapid steps accumulate into one accelerating travel instead of five restarted
fades. Linear, not a ring; neighbours exist only mid-flight; the soft edge is per-option opacity,
never a mask (a mask rasterises the projection away — the Apple file's own field verdict). The
band's width is fixed by the row, so a step can never reflow the chevrons; portrait narrows it
(132 dp) because at 156 the LABELS truncated, and a clipped label loses meaning where a drum value
only loses its tail into the edge fade. Chevrons are icons now, not '‹' text glyphs.
**Landscape got room.** Rows cap at the Apple client's 620 dp and sit left; the focused row's
description moved into the width a wide phone was wasting — a side pane on the right, with the
row's label anchoring it — instead of a band floating over the list's tail. Portrait keeps the
band. Add-host fields take the same cap.
**The list is no longer guillotined.** The safe area now applies to the CHROME (strip, sides, top)
only; the list runs to the physical bottom edge with the bottom inset folded into its
contentPadding, so scrolled rows glide off the screen instead of being cut at an invisible line
above it.
**"Default settings" stopped shouting.** The headline that repeated itself on every tab is a quiet
"Defaults" chip at the strip's end — same honesty (this screen edits the base layer only), no
second heading repeating the tab pill's own word.
**Tab switching is finally directional.** The old cut slid a single list's contents 24 dp under an
85 % fade — the same crossfade whichever shoulder was pressed. It is now an AnimatedContent whose
incoming section slides from the side the press pointed at while the outgoing leaves the other way.
Each pane owns its own LazyListState (one state cannot attach to two lists — the constraint that
motivated the single-list hack), seeded at the section's restored cursor.
**The carousel ignores the safe area again, on purpose.** Insetting the pager clipped the fanned
neighbours at the cutout edge; cards visibly cut off is worse than cards behind a camera. Only the
centred card matters and it sits mid-screen — the title and legend keep their insets, they are
content.
**The legend stopped jumping.** The connect takeover parked its pill at bottom-CENTRE, so pressing
Connect made the one piece of chrome that should read as fixed leap halfway across the screen. It
now sits at the same bottom-start inset as every console screen.
|
||
|
|
f62a48d4a9 |
feat(library): launcher tiles get their launcher's logo — a brand token on the wire, the vector in every client
apple / swift (pull_request) Successful in 1m42s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m12s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m32s
ci / rust-arm64 (pull_request) Successful in 1m52s
ci / web (pull_request) Successful in 1m6s
ci / docs-site (pull_request) Successful in 1m15s
ci / bun-nix (pull_request) Successful in 18s
android / android (pull_request) Successful in 3m56s
ci / rust (pull_request) Successful in 5m40s
A launcher tile (role: "launcher", design D4) shipped no art on purpose:
a launcher's own icon is square, every client cover-crops a 2:3 poster,
and the crop turns a mark into a strip. So the tiles were the launcher's
name on a flat accent face — legible, and the blandest thing in the grid.
Entries now carry an optional `icon`: the NAME of a brand mark, never
image bytes and never a URL. `[a-z][a-z0-9-]{0,31}`, shape-validated by
the host on every lane (a client interpolates the value into a resource
name or an asset lookup, so the guard belongs upstream of all of them,
and each client re-checks rather than trusting the peer).
A token rather than art because the alternative is closed by
construction, and deliberately: the art proxy serves what the bytes ARE
(sniff_image_type) and SVG is not on that list — it is script-capable
XML and the console renders library art in a browser. Widening that
sniff would trade a rendering nicety for a stored-XSS surface. Naming
the mark keeps the refusal intact, keeps the glyph vector at whatever
size a tile happens to be, lets it take the tile's ink, and adds nothing
to a reconcile payload that is already body-limited. The cost is that a
third-party plugin cannot ship a mark no client bundles; its tile falls
back to the launcher's name, exactly as before, and the fix is a PR
adding the master.
assets/launcher-icons/ holds seven monochrome masters with per-mark
provenance and licensing (Simple Icons CC0: lutris, heroic, epic, gog;
Font Awesome CC BY: steam, xbox; Playnite's own logo, MIT). steam is
generated FROM assets/os-icons/steam.svg so the SteamOS host badge and
the Steam launcher tile can never drift.
scripts/gen-launcher-icons.sh bakes the three derivatives that cannot
consume a master (GTK symbolic SVG, Windows PNG, Apple template PDF)
and — unlike gen-os-icons.sh, which prints path data for a human to
paste — GENERATES the three inline registries (web console, Android
ImageVector, pf-console-ui Skia). Three clients x seven paths of up to
3 kB is a transcription error waiting to happen, and a mangled character
is a silently wrong logo rather than a build failure. The generated Rust
goes through rustfmt, since `cargo fmt --all --check` is a CI gate and a
generated file that fails it would fail every regeneration.
All six renderers draw the mark CONTAINED, never cover-cropped: the
masters' viewports are not square (steam 496x512, playnite 1024x1024)
and filling a 2:3 frame would reproduce the strip this exists to avoid.
Every one keeps its old fallback for a token it has no art for.
Epic, GOG and Xbox marks ship dormant. Those plugins' launcher switches
are off by default and emit nothing, because the host has no verified
launcher_ui activation for them yet — shipping the art now keeps turning
one on the one-line plugin change those plugins promise, instead of also
needing a release of all six clients.
api/openapi.json and the SDK are regenerated (the spec's version field
was stale at 0.25.0 and now reads 0.26.0, which is the crate's actual
version — an unrelated line that regeneration necessarily corrects).
Verified: host cargo check, clippy -D warnings across pf-client-core /
pf-console-ui / punktfunk-client-session / punktfunk-client-linux, plain
build, pf-console-ui tests (77, including a new one asserting all seven
masters parse under Skia and one asserting the letterbox stays inside
its box), pf-client-core tests (188), cargo fmt --all --check, Apple
swift build, Android compileDebugKotlin, web tsc + vite build,
plugin-kit tsc, biome. The Windows client is NOT compile-verified — it
cannot be built from a Mac (scripts/xcheck.sh covers only the capture
stack by design) and CI does not build it either; its tile change needs
a real box before it ships.
|
||
|
|
f90a4e5f97 |
refactor(android): ConnectScreen splits, and the console can finally open Controllers and Licenses
WP9.2 and WP8.3 of the console visual-refresh plan. **WP9.2 — the 1361-line ConnectScreen becomes 979 plus three files.** `HomeTiles.kt` holds a pure `buildHomeTiles` (non-composable, so it is unit-testable — `HomeTilesTest` pins six behaviours the console carousel had no cover for at all, including that a saved host also advertising on a NEW DHCP address is listed once, which exercises the fingerprint match rather than just "the builder lists what it is given"). `ConnectGrid.kt` holds the touch home. `ConnectPrompts.kt` holds everything modal. `ConnectScreen`'s signature is untouched, so `App.kt` compiles unchanged. What stayed, deliberately: the state and the engine — discovery, the permission dance, identity, the trust decision, the dial and its wake fallback, the deep-link router. Those close over ~20 locals that a dozen callbacks read AND write, so lifting them means inventing a state holder, which is a second refactor and a second thing to get wrong. A comment above `ConnectScreen` now says so. 🛑 **A real bug the split surfaced, fixed here:** the console carousel was live under a FINISHED speed test. It looked covered by `!connecting`, and was — until the measurement ended, because `startSpeedTest` clears `connecting` before the Done card is dismissed. From that moment the card and the carousel underneath both consumed the pad, so one A dismissed the card *and* started a connect. `speedTest` now sits in the `navActive` gate with every other modal. **WP8.3 — two screens the console could not reach.** On a TV box there is no touch interface to fall back to, so the notices and the controllers view were simply unreachable. Both are now console screens at nav depth 2 (reached FROM settings, which is what makes the trip a push and the way back a pop), opened by a Controller-tab row and an About row. `GpSettingsPlace` carries the cursor across the trip, keyed by row ID rather than index, so Back lands where you left rather than on the first row of the first tab — and because a tab's length follows the hardware, an index would have been the stale-pointer bug the tab-switch clamp already exists for. Four blockers, all real: * 🛑 `ControllersScreen` installed the shared input probes unconditionally and NULLED them unconditionally on dispose — no identity check, unlike `GamepadNavEffect2D`. During the shell's push/pop both screens are briefly composed, so its teardown would have killed the incoming screen's pad navigation. Now it releases only the slot it still owns. * 🛑 `LicensesScreen` had exactly ONE focusable node, and Compose only scrolls to keep a FOCUSED child visible — so a D-pad could not read past the first screenful of a many-screen file. Both screens now drive their scroll state directly: up/down steps 0.28 of the viewport, shoulders page 0.88 — under a screenful on purpose, so the line you were reading survives the press. * ⚠ Both were inked from the TOUCH theme (28 `colorScheme` sites plus implicit pulls from `OutlinedCard`, `Switch`, `OutlinedButton`, `LinearProgressIndicator`), which is always dark — invisible over the six PALE palettes. They are now shown through one `ColorScheme` derived from `LocalGamepadInk` rather than 27 call-site branches, because call-site edits cannot reach the implicit pulls at all. Screenshot scenes shoot both on a dark and a pale palette; the pale pair is the point. * ⚠ B was already taken — the input test's exit is a 1.2 s hold. The rule is now stated on screen: while the test runs the pad is the test's, a short B answers with the boundary thud instead of doing nothing, and the legend collapses to one "Hold to finish" cell. 🛑 **Second bug fixed in passing:** that hold ended the test AT the 1.2 s mark, so the B *release* then fell through to MainActivity's B→BACK remap and closed the whole screen. It bit the touch screen too. The test now ends on the release, which is therefore consumed. Residual TV gap, flagged not fixed: the Controllers screen's inner buttons ("Grant USB access", "Test rumble", "Test haptics") have no console focus list, so they stay touch-only — a denied Sony USB grant still has no console recovery path. |
||
|
|
430c9831ef |
refactor(android): the prompts that say the same thing in both interfaces are now one prompt
WP9.1 of the console visual-refresh plan — dialog unification. Six prompts existed twice: a Material `AlertDialog` in `ConnectDialogs.kt` and a console glass card in `GamepadDialogs.kt`, maintained by hand. They had drifted, and always in the same direction — the console losing something: * "Pair with PIN…" and "Use a PIN…" lost their ellipses, so the console said the buttons finished something the touch UI said would open another step; * "if no prompt appears when you tap Allow" became "after Allow"; * the speed test dropped `speedTestTargetNote` entirely, leaving a console user — often on a TV box, which is exactly the machine whose link is worth measuring — no statement of which layer "Apply" was about to write to. That is a write in an unknown direction. What is shared now is the DESCRIPTION of a prompt (a title, a list of `DialogAction`s, a body) and what stays per-interface is only how it is drawn. `PunktfunkDialog` takes that description and renders it as an AlertDialog or as the existing console modal. Actions are ordered primary-first: the console stacks them that way with the cursor on the first, and the touch renderer lifts the same first action into `confirmButton`. One order, two idioms. The two renderers cannot be one tree — an AlertDialog composes into its own platform window while the console modal is a Box in the calling tree, which is why one needs a `BackHandler` and the caller's `navActive` gate and the other needs neither. Deliberately NOT unified, and they belong apart: the PIN ceremony (a keyboard field and an editable device name against four D-pad digit slots is a different input model, not a different skin), Add/Edit Host (a bottom sheet against a full screen with its own on-screen keyboard), and the host action list (an anchored dropdown against a modal stack that also grows a row per profile). Twelve composables become six. `ConnectScreen`'s dialog block loses ten `if (gamepadUi)` branches. |
||
|
|
4b48748b8a |
feat(android): the console can finally decide a host's clipboard and profile — and says what it is doing
WP8.2 and the rest of WP8.7 from the console visual-refresh plan.
**The console's Edit Host was missing two decisions entirely.** The touch edit sheet has always
offered a shared-clipboard switch and a profile binding; the console form built only name, address,
port and MAC. Nothing was ever LOST — `KnownHost.copy` preserved both — but a couch-only user could
never CHANGE either, and a TV box has no touch interface to fall back to. Both are now rows in the
console form, driven like settings rows: left/right steps them, A flips or cycles. The binding is
filtered through the live catalog, so a host bound to a since-deleted profile reads as unset rather
than as a name nothing resolves — the same guard the touch sheet applies. "Default settings" leads
the ring as the ABSENCE of a binding, not as a fake catalog entry.
**Accessibility, finished.** The library's coverflow now says which poster a press acts on — from
the art alone a centred cover and a neighbour are indistinguishable, and only the centred one
launches. The group heading is a live region: it is the sole signal that the cursor has crossed
from the launchers into the games, and a coverflow gives a reader no other way to notice, being one
strip rather than two lists. The store badge says why it is there ("Opens Steam" / "From Steam")
instead of reading out a bare vendor name after the title.
|
||
|
|
0b550dad84 |
feat(android): the console tables stop drifting in silence, and the stats overlay gets a pad route
WP8 and WP9 of `punktfunk-planning/design/android-console-ui-visual-refresh.md`, in part. **WP9.3 — shared parity vectors.** The console's background palettes, its settings section names and its screen-transition motion each existed in three hand-written copies (`pf-console-ui`, this client, the Apple client) held together by a comment asking the next person to keep them in step. `clients/shared/console-vectors.json` now holds them, read the way `deeplink-vectors.json` already is: `include_str!` in Rust, a relative path in Kotlin, `#filePath` in Swift — never a copy, because a copy is a fourth contract free to go stale. It carries the DERIVED tables too, the 16-cell mesh and the 4 blob colours per palette, which is the half that reaches the screen and the half Android never checked: `GamepadPaletteTest` only ever measured the `stops` they are computed from. Two drifts it immediately caught, both now closed: * **The easing was the wrong curve.** `ConsoleMotion.EaseOutCubic` shipped as `cubic-bezier(0.215, 0.61, 0.355, 1)` while claiming to be the desktop's `ease_out_cubic`. It is not: that is the Penner/Ceaser table's curve, ~0.80 at the midpoint where `1 − (1−t)³` is 0.875 — visibly slacker over a 260 ms transition. Compose's `Easing` is a plain function, so it now evaluates the real thing analytically rather than approximating it at all. (Apple approximates with a different bezier only because SwiftUI's `timingCurve` cannot take a closure; the vectors sample the curve with a tolerance so all three can meet it.) * **The desktop has a seventh tab.** Input — touch mode, mouse, invert-scroll, shortcuts — with nothing to set on a phone or a TV. `settings.rs` claims in prose that a setting is found under the same word on every client; that was true modulo an omission nobody could see. The vectors model it with `desktop_only` rather than picking a side, so neither client has to be wrong. Rust reads it from three tests placed in the files that own the constants, so nothing had to be made `pub` to be checkable. Verified green under Linux (the crate is `cfg(linux|windows)` throughout — `cargo test` on a Mac compiles nothing and passes vacuously): 77 passed, 0 failed. Android's side gates in CI as a FILTERED task; a plain `:app:testDebugUnitTest` would drag the ~20 Roborazzi screenshot scenes into every push, and those are a release-artifact job. **WP8.1 — a pad route to the stats overlay.** The tier could only be cycled by a three-finger tap, which does not exist on a TV, on a gamepad-only session, or under touch passthrough — while the settings row promised a live cycle. `Select + X` now cycles it, byte-identical to the Apple client's `GamepadWire.back | GamepadWire.x`, implemented as the mic chord's twin in `GamepadRouter` and edge-triggered on the button that completes the mask. The buttons still reach the game, as both existing chords do. `GamepadChordTest` pins eight cases the kit had no cover for at all, including that the three chords intersect only on Select and that none is reachable through another. **WP8.5 — a start-of-stream banner.** The desktop's `skia_overlay` banner, ported with its timing (opaque 5.4 s, then a 0.6 s fade) and its rule of naming only shortcuts that exist: pad chords when a pad is present, the touch gesture when there is a touchscreen and the mode can use it. Nothing `Ctrl+Alt+Shift` is advertised, because Android has none of it. It yields to the motion-unreachable notice rather than stacking with it — that one reports something broken about *this* session. **WP8.6 — the home card says which profile it connects with.** `HomeTile` carried a `pinnedProfileId` the card never drew, so a pinned host+profile card was distinguishable from the host's own only by a subtitle that had been quietly repurposed to hold the profile name. Both now show the address like every other card and wear a tinted profile chip — the touch grid's own convention and the Apple client's, inked from the console palette. Unsaved tiles (discovered, Add Host) take a dashed edge, which is what the other two surfaces already use to say "not yours yet". ⚠ Not a detail panel: the Apple client REMOVED its own and moved the status onto the card, which is where the lock and the online pip already were here. **WP8.7 — accessibility, in part.** The console screens carried three `contentDescription`s and no `semantics`, `Role` or `stateDescription` at all. A settings row now announces once, merged — label, value, and the description that lives in the floating band far from it — with `Role.Switch` and a real toggle state, because a toggle row's on/off string was drawn by nothing at all: the switch replaces the value text, and the switch was two undescribed `Box`es. Decoration is silenced rather than labelled (the chevrons were read aloud as punctuation on every focused row). The hint bar's glyphs, the tab strip and the home tiles are done; `GamepadAddHostScreen` and `LibraryScreen` are not yet. |
||
|
|
cac500acc7 |
feat(android): the console UI stops moving under the cursor, and gets the desktop's real backdrop
Implements P1–P3 (WP1–WP7) of `punktfunk-planning/design/android-console-ui-visual-refresh.md`. **The two named bugs.** Settings rows GREW ON FOCUS. The focused row unfolded its description in place (`AnimatedVisibility` + `expandVertically`), so every D-pad step shrank one row and grew another and shifted every row below the cursor — on a list simultaneously being scrolled to keep the focused row visible, whose target therefore moved out from under it mid-animation. Sideways, the value's `AnimatedContent` animated its own WIDTH on every step, walking the ‹ chevron back and forth. Rows are now fixed: the description renders in a floating `ConsoleDetailBand` above the legend pill — the desktop console's reserved detail band (`screens/settings.rs`) achieved by FLOAT rather than by subtraction, so an overlay can never displace the list. Apple's per-row `reservesSpace` was rejected for density. The value slot gets a fixed minimum, end alignment, a snapped `SizeTransform` and tabular figures, so `1920 × 1080 → 2560 × 1440` changes nothing but the glyphs. `GamepadSettingsLayoutTest` is the regression net — it drives the real screen and asserts the neighbouring rows' bounds and the focused label's bounds do not move. The NP3's HOLE PUNCH sat over settings and add-host content. All twelve console call sites padded with `systemBarsPadding()`, which EXCLUDES `WindowInsets.displayCutout`; in landscape a punch is a left/right edge inset with no bar behind it. New `Modifier.consoleSafeArea()` is `systemBars ∪ displayCutout` (the rule Material3's own components lay out against) and the floating legends' landscape branch keeps ignoring the bars while still taking the cutout — `SENSOR_LANDSCAPE` parks the punch on exactly that corner. The backdrop stays full-bleed; it is ambience, and running under the camera is what ambience should do. **The premium-feel levers.** The screen transition is now the desktop console's contract instead of `Crossfade(240)`: push slides the incoming screen up 36 dp out of a fade at 0.985→1 while the outgoing recedes to 0.96; pop runs it backwards. Direction comes from a nav depth on `GamepadScreen`. `ConsoleMotion` is the single motion vocabulary (`shell.rs:29`'s `TRANSITION_S`, `render.rs:120-151`'s geometry, plus the focus/value/tab tweens that were scattered literals) and `ConsoleShape` the corner radii that were four literals in as many files. Reduce-motion collapses push/pop to a 90 ms cross-fade. The backdrop is the desktop's actual field: `mesh_sksl` (`library.rs:433-525`) ported to AGSL — 4×4 bicubic colour mesh, four drifting interior warp points, ±8° hue sway, vignette, scrim, `calm` — compiled per palette and cached, gated on API 33+ (`RuntimeShader`). `GamepadPalette` already carried `CELL_RAMP`; it grows `MESH_COLORS`, `MESH_INTERIOR` and `meshColors`, so the 16-cell table is now literally shared with the Rust. API 28–32 keeps the four-blob field, and a shader that fails to compile falls back to it rather than taking the console down. The `cross()` builtin is written out by hand — AGSL's function set is a subset of SkSL's and not worth betting on. Constants are formatted through `Locale.ROOT`: on a German-locale device `String.format` emits `0,075`, which is a syntax error in shader source. `Modifier.consoleGlass` gives every row, field, card and tile a vertical luminance gradient, a 1 px top-edge highlight fading into the border, and — with focus — a drop shadow plus an accent bloom drawn outside the clip, so focus reads as a lens rather than a recolour. Modals get `consoleCard()`, which fixes a real bug: the cards were a hardcoded near-black indigo while their text came from the palette, so on any of the six PALE palettes a dialog rendered dark ink on a dark card and was unreadable. `GamepadInk` grows `card`, `modalScrim`, `highlight` and `danger`. Micro-interactions: menu haptics (tick on move, thud on a refused press, pulse on confirm) on the driving pad's own motors, else the phone body, else silence — a TV has neither; a refused value step nudges 4 dp toward the press and springs back; the pressed-side chevron ticks 2 dp; the switch knob squashes at mid-travel; one indicator pill GLIDES between the section tabs instead of six fills cross-fading; the home carousel dips 0.97 under A and its neighbours turn away on the Apple client's projected `cos` fan (never `rotation3DEffect` — see its note on the settle flash). Palette leaks closed: the library's group heading was hardcoded white (invisible on a pale field), the poster ground a fixed indigo, and the launcher badge read `MaterialTheme.colorScheme.primary` — the TOUCH theme's wallpaper-seeded colour, nothing to do with the field it sat on. The add-host caret and the keyboard's fill went the same way. The home header now outranks the controller chip, which was eating "Select a Host" down to "Selec…" on a 360 dp phone. Screenshot scenes gain the console home on the mesh (API 36), the same home on the blob fallback (API 31), a landscape home where the carousel's fan is visible, and a pale-palette pass of each. Not in this change: WP8 (functional parity gaps) and WP9 (structural) — the plan's own P4/P5, and the dialog cards' backdrop frost, which would need a `HazeState` plumbed through `ConnectScreen` to be worth anything behind a 94 %-opaque surface. |
||
|
|
beb639f067 |
fix(ui): the OLED palette is called Eclipse now
Every other background reads as a place or a material — Violet, Nebula, Abyss, Ember, Moss, Graphite, Holo, Sunset, Bloom, Dawn, Mint, Opal — and "OLED" was a spec sheet sitting among them, naming the panel it suits rather than what it looks like. It looks like black with a thin violet corona at one corner, which is an eclipse, and that puts it beside Nebula and Abyss. The ID stays "oled" in all three clients: it is the stored ui_palette value AND the cross-client key, so renaming it would orphan every saved choice and silently desync Apple, Android and the console UI. Only the label moved. No test or doc pinned the old string. Palette tests pass; macOS builds. |
||
|
|
30bd10e301 |
feat(clients): an OLED palette, and split WHETHER the gamepad UI is offered from WHEN it appears
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / web (pull_request) Successful in 1m39s
ci / rust-arm64 (pull_request) Successful in 4m7s
android / android (pull_request) Successful in 5m1s
ci / docs-site (pull_request) Successful in 1m47s
ci / bun-nix (pull_request) Successful in 42s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m19s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m23s
ci / rust (pull_request) Successful in 14m32s
Four changes to the client interface, kept together because two of them touch the same rows
and the last is a bug the first would have made far more visible.
A thirteenth `ui_palette` entry, `oled`. The palette table is hand-mirrored in three languages
(`pf-console-ui`'s `library.rs`, `GamepadPalette.swift`, `GamepadPalette.kt`), so it goes into
all three at index 1, directly after the brand default — which keeps `PALETTES[0]` the unknown-id
fallback and keeps the dark-to-pale cycling order intact. What earns the name is arithmetic, not
a darker shade of violet: the ramp's first two stops are literally (0,0,0) and the ground is pure
black, so the shaded half of the field is pixels switched off rather than "very dark grey", and
the calm mix the form screens sit under lifts toward nothing at all. Mean cell luminance is 0.019
against Violet's 0.254. The bright corner keeps a faint indigo-to-violet ember so the backdrop is
still a field with somewhere to go, and that ember carries enough chroma at that luminance
(60 degrees of hue travel across 13 of the 16 cells) to satisfy the existing multi-tone assertion
without adding `oled` to the near-neutral exemption Graphite and Opal take. Each port gains an
`oled_is_actually_black` test that measures the claim — pure-black corner cells, a mean under half
the darkest other field's — rather than restating the table.
A new device key, `gamepad_ui_mode`. The gamepad-UI switch had been deciding two things at once:
whether to offer the controller-optimized interface at all, and that it appears only while a pad
is attached. A user asked for the second half to stop applying. `"connected"` (the default, and
exactly what the lone Bool meant) and `"always"` separate them, surfaced as a "Show it" row
directly under the switch on all five settings surfaces and built only while that switch is on —
a picker whose every option decides nothing is worse than no picker. `GamepadUIEnvironment.isActive`
takes the mode with NO default argument on purpose: a call site that forgot it would silently
strand everyone who chose Always back on "only with a controller", which is the one bug this
parameter exists to make impossible. An unrecognized value waits for a controller, so a mode a
newer client wrote can never trap an older one in a layout it has no way back out of. It stays a
device preference on both platforms, never part of a profile: which interface this device wears
has nothing to do with how a host streams to it.
The smoothness buffer is hidden under Lowest latency, not dimmed. Everywhere else already hid it
— the GTK and WinUI shells, the Apple touch and tvOS screens, the Android touch screen — because
under that intent it names a quantity that does not exist. Two surfaces disagreed: Apple's gamepad
settings screen left the row live and steppable, and the desktop console dimmed it, having no way
to drop a row from a fixed list. That list is now rebuilt each frame through a `row_applies`
filter. The concern about a vanishing row moving everything under the cursor does not apply here
and the new test says why: the row it drops sits directly BELOW the row that drops it, so the only
cursor that can be present when the list shrinks is the one on the intent row, which does not
move. Two latent hazards went with it — `apply_row` had been indexing the row list on the
assumption the cursor is always in range, and nothing re-clamped that cursor when another writer
changed the intent behind the screen's back.
Pale palettes were unreadable on tvOS, reported from the field. `GamepadInk` was never the
problem: it flips correctly for a pale field, it is not platform-gated, and every tvOS gamepad
entry point already published it. The cause is that this app sets `preferredColorScheme` nowhere
and declares no `UIUserInterfaceStyle`, so every SYSTEM-derived colour landing on those screens —
a `.secondary` placeholder, a `.bordered` button's chrome, a NavigationStack title, a material's
frost — resolved against the DEVICE appearance, which the palette cannot reach. On iPhone, iPad
and Mac a great many users sit in Light mode, so under a pale palette those colours came out dark
and the theme looked correct by accident; an Apple TV is Dark essentially always, so every one of
them rendered white on a light field. The mirror image was broken too and had simply never been
reported: a dark palette on a Light-mode iPhone was already drawing dark on dark. The scheme is
now published beside the ink, once, in `GamepadInkModifier`, because the two are halves of one
decision and publishing only the ink silently loses every colour the frameworks draw on the app's
behalf. Two structural amplifiers went with it: `ConsoleGlass` had been scoping the scheme to the
fill inside its `.background {}` on the tvOS and pre-26 branches while the 26 branch put it on the
content, so no console row's own content ever saw it on tvOS; and `LibraryView`'s navigation
chrome and its loading, error and empty states sit above `LibraryCoverflowView` and so were never
inked at all on tvOS and macOS, where that view is presented directly rather than through the
iOS-only `GamepadLibraryScreen` wrapper.
That last one exposed a second tvOS gap worth closing in the same breath: `ui_palette` had no row
in tvOS's ordinary Settings, and the gamepad settings screen that owns it everywhere else needs an
extended-profile controller to open on tvOS. An Apple TV driven by the Siri Remote alone could not
reach the palettes at all, which would now include the OLED one. `SettingsView.tvBody` carries a
Background row.
Verified: pf-console-ui builds, passes `clippy --all-targets -D warnings` and runs 74 tests clean
under linux/amd64 (a Mac `cargo check` of that crate is vacuous — every module is cfg'd to
linux/windows); `cargo fmt --check` clean for it and pf-client-core. Android `:app` runs 80 tests
with 0 failures, including four new `gamepadUiActive` cases and the palette parity table. The
Apple package builds for macOS AND tvOS and its 9 palette/gamepad-UI tests pass — the tvOS
typecheck is possible because the checked-in xcframework already carries a `tvos-arm64` slice. The
tvOS RENDERING fix is compile-verified only; an on-glass Apple TV check under a pale palette is
still owed, and is the one thing here that a build cannot answer.
|
||
|
|
deeb8b6700 |
feat(pf-encode): build against FFmpeg 9
apple / swift (pull_request) Successful in 1m53s
apple / screenshots (pull_request) Skipped
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 2m34s
ci / web (pull_request) Successful in 2m32s
ci / docs-site (pull_request) Successful in 1m25s
ci / bun-nix (pull_request) Successful in 26s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 3m23s
android / android (pull_request) Successful in 6m47s
ci / rust-arm64 (pull_request) Successful in 8m49s
nix / flake (pull_request) Failing after 16m7s
ci / rust (pull_request) Successful in 23m39s
ffmpeg-next 8.1.0 could not accept FFmpeg 9 at all: ffmpeg-sys-next's version probe
covered avcodec majors 56..62 (the range is exclusive of its end), so libavcodec 63 fell
outside what it knew how to bind. 9.0.0 widens that to 56..63, which is what actually
unblocks Arch. Bump both pins — the unconditional Linux dep and the optional Windows
amf-qsv one — and the lock with them.
No API drift to fix. The crate major is a CEILING, not a target: one source tree still
spans FFmpeg 7.x/libavcodec 61, 8.x/62 and 9.x/63 via per-version cfgs, and every wrapper
symbol the NVENC-libav, VAAPI and amf-qsv backends name survives 8.1.0 -> 9.0.0
unchanged. The three hand-written #[repr(C)] hwcontext mirrors are the parts no compiler
checks, so they were re-read against the real headers rather than trusted:
AVCUDADeviceContext and AVD3D11VAFramesContext are byte-identical across 7.1/8/9, and
AVD3D11VADeviceContext gained two trailing UINTs in 8 that 7.1 lacks — which is why that
mirror deliberately stops at the common prefix, and why its assertions now say what they
do and do not buy you. They pin our layout, not libav's; a green build is not evidence.
The CI image is the step that makes this reach users. arch.yml deliberately runs no -Syu
("the image's snapshot IS the build environment"), so the builder stayed frozen on ffmpeg
8 no matter what Arch shipped, and a canary built from that snapshot could not satisfy the
soname dep the PKGBUILD now derives. Re-keying ci/ rebuilds it against ffmpeg 9.
Ubuntu and Windows deliberately stay put: the noble .deb bundles its own FFmpeg 8 behind
an rpath and strips the libav sonames from its Depends, and Windows bundles BtbN DLLs into
the signed installer — neither is exposed to the break, BtbN publishes no FFmpeg 9 build,
and moving either would re-qualify an encode stack to buy nothing.
Verified end to end on 192.168.1.21 (CachyOS, system ffmpeg 2:9.0-5, RTX 5070 Ti): host
builds clean and links libavcodec.so.63/libavutil.so.61/libavfilter.so.12/libswscale.so.10
with no unresolved sonames; the ffmpeg-8 compat shim is gone and the service runs with
NRestarts=0 and answers 401 on :47990; pf-encode's 67 tests pass; and a live synthetic
encode drives real NVENC hardware through FFmpeg 9's libavcodec to a decodable 1080p HEVC
stream (180/180 frames, FEC loopback 0 mismatches) with libavcodec.so.63 and
libnvidia-encode both mapped into the encoding process.
|
||
|
|
bfed711921 |
Merge remote-tracking branch 'origin/main' into audio/latency-overhaul
ci / bun-nix (pull_request) Successful in 33s
ci / web (pull_request) Successful in 1m9s
ci / docs-site (pull_request) Successful in 1m21s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m13s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m36s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m9s
android / android (pull_request) Successful in 7m53s
ci / rust (pull_request) Successful in 12m23s
|
||
|
|
74270109dd |
ci(android): lint the Android target, which nothing had ever done
`ci.yml` runs `cargo clippy --workspace` on the HOST, where `clients/android/native` and every `#[cfg(target_os = "android")]` module elsewhere compile out, and `android.yml` only ever built. So the Android target was never linted at all — not once. Five lints were sitting in clients/android/native when this was noticed, in code no gate had ever read. The gate is a Gradle task rather than a YAML step because cargo-ndk needs a specific discovery environment (NDK sysroot, SDK cmake 3.22.1 for libopus, `LIBOPUS_STATIC`, Ninja) and duplicating it into the workflow would let the lint drift from the build — a lint that ran against a different toolchain is a lint about a different program. `registerCargoNdkClippy` reuses the build task's environment verbatim via the extracted `cargoNdkEnvironment`, so local and CI runs are the same invocation. It lints BOTH pointer widths, and that is load-bearing rather than thorough: arm64-v8a is 64-bit and armeabi-v7a is 32-bit, so a cast that is redundant on one can be required on the other. Linting only the primary ABI would license "fixes" that break the 32-bit build — the shipping ABI for the many 32-bit Google TV / Android TV boxes this client targets. x86_64 is skipped: it is emulator-only and shares its width with arm64, so it costs lint time for no signal the other two do not already carry. The five resident lints: * `audio.rs` / `mic.rs` `type_complexity` — the open-attempt closures now return named `OpenedPlayback` / `OpenedCapture` aliases. The two tuples are mirror images of each other (playback sends, capture receives), which the aliases now say out loud. * `vsync.rs` ×2 `unnecessary_cast` — **not** taken. `timespec`'s fields are 32-bit on armv7 and 64-bit on arm64, so the casts are REQUIRED on one shipping ABI and redundant on the other; following the suggestion would break the 32-bit build. `i64::from`/`.into()` do not escape it either, they trade `unnecessary_cast` for `useless_conversion` on the 64-bit side. Answered with a documented `#[allow]` at the expression instead of in whichever build breaks first. * `pad_audio.rs` `needless_range_loop` — iterator form, preserving the `channels < 2` no-op the range had. Verified: `:kit:cargoNdkClippy` green on both ABIs, host-lane clippy for the crate still clean, `cargo fmt --all --check` clean. The gate was proven non-vacuous by planting `1i32 as i32` in an android-only module and confirming it fails the task, then reverting. |
||
|
|
70e6b80200 |
fix(client/android): place audio with the picture on Android too
The core, Linux, Windows and host halves of the audio latency overhaul landed with Android deliberately left inert: `JitterPolicy`'s sync target defaults to `None`, so this ring kept behaving exactly as it always had. What was missing was not the loop but its REFERENCE — nothing here published where a frame actually reached glass, and a controller with no reference is the mechanism you can prove is present but that cannot act. This wires both halves. The decode thread now reads the host capture `pts_ns` that every `AudioPacket` has always carried and that this client, like every other, dropped on the floor. Against the ring depth (published by the AAudio callback through the shared `AudioSyncCell`) and the video plane's end-to-end figure it computes audio_e2e = (now + buffered_ahead + clock_offset) − pts_ns av_offset = audio_e2e − video_e2e (> 0 ⇒ audio behind the picture) and asks the ring for a depth that closes it. Only ASKS: `set_sync_target` is clamped between the underrun-driven adaptive floor and the hard cap, so a link whose jitter genuinely needs more buffer than the picture is away keeps its buffer and the residual is reported instead of being taken out of the listener's stream. Continuity outranks sync, on this ring as on the others. The reference comes from `DisplayTracker`'s `OnFrameRendered` callback — the one place in the client that knows a frame truly latched — and it is computed ABOVE the HUD gate now. A sync loop that only ran while the overlay was up would be off on exactly the devices that report latency; the stats LOCK stays gated, which is what that early-return was really protecting. Both decode loops feed it, so sync works with "Low-latency mode" off as well. Two deliberate refusals: * The figure is published RAW. The HUD shaves the OS present floor off its shown display/end-to-end numbers — metrics report what Punktfunk controls — but sound has to reach the ear when the light reaches the eye, and a floor-shaved reference would place audio a whole latch period early on every device. * Below API 33 there is no render callback, so there is no confirmed present and the loop stays inert (target `None` ⇒ today's behaviour exactly). The release instant is NOT substituted for it: a release targets a FUTURE vsync and runs a whole latch period (8-21 ms measured) ahead of glass, well outside the loop's deadband — it would place audio early on every frame while looking like it was working. The plane is also no longer invisible. Ring depth and the smoothed offset ride the stats array at 33/34 and the Detailed HUD carries `audio buffer N ms · a/v ±N ms`, the same wording the desktop HUD uses — both numbers, because a deep ring on a jittery link is correct behaviour and only the offset separates that from audio simply held late. The 1 Hz logcat line gains `av_ms` beside its depth, and the depth itself now has ONE publisher: the counter copy is gone in favour of the sync cell both readers already share. The escape hatch is two levers. `PUNKTFUNK_NO_AV_SYNC=1` keeps the contract the desktop clients document, but an app launched from the launcher inherits no environment, so the one a field tester can actually reach is `adb shell setprop debug.punktfunk.no_av_sync 1` — no rebuild, exactly like `debug.punktfunk.presenter`. A loop that steers playback has to be bisectable on the device that reports the regression. Verified: `cargo ndk -t arm64-v8a check` clean; `cargo clippy -p punktfunk-client-android --all-targets -- -D warnings` clean on the host lane CI lints, and the Android target introduces no new findings (5 pre-existing lints in audio/mic/pad_audio/vsync are unchanged — the android-gated modules are never linted by the host workspace); `cargo fmt --all --check` clean; `./gradlew :app:testDebugUnitTest` green. The new HUD test was proven non-vacuous by planting the defect first — dropping the render call fails its three positive assertions and leaves the three absence assertions passing, which is the shape a test that "passes for the wrong reason" would not have. design/audio-latency-overhaul.md W4. Apple (W6) still keeps today's behaviour. |
||
|
|
ee61e8c9ba |
fix(clients/pads): the phone mirror never needed the controller path's frame change
ci / bun-nix (pull_request) Successful in 30s
ci / docs-site (pull_request) Successful in 1m16s
ci / web (pull_request) Successful in 1m22s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
windows-drivers / driver-build (pull_request) Successful in 1m38s
ci / rust-arm64 (pull_request) Successful in 2m12s
windows-drivers / probe-and-proto (pull_request) Successful in 21s
android / android (pull_request) Successful in 3m39s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m11s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m14s
ci / rust (pull_request) Successful in 6m32s
Reverts half of |
||
|
|
7a4cdac5b7 |
fix(client/android): a Bluetooth pad's gyro obeys the same reachability gate as the rest
ci / bun-nix (pull_request) Successful in 26s
ci / web (pull_request) Successful in 1m8s
ci / docs-site (pull_request) Successful in 1m9s
ci / rust-arm64 (pull_request) Successful in 1m34s
windows-drivers / driver-build (pull_request) Successful in 1m34s
android / android (pull_request) Successful in 3m30s
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m15s
windows-drivers / probe-and-proto (pull_request) Successful in 18s
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m6s
ci / rust (pull_request) Successful in 6m34s
apple / swift (pull_request) Failing after 11m43s
apple / screenshots (pull_request) Skipped
Follow-up to the G10 merge. The new sensor path was written against main, which does not carry this branch's G8 work, so it forwarded motion unconditionally — the one thing G8 exists to stop. `deviceMotion` checked `forwarding` and nothing else. A Bluetooth DualSense in a session that resolved to an X-Box backend would stream ~200 Hz of samples the host parses and discards, for the whole session, exactly as the USB capture path did before G8. Not a regression against shipped behaviour — the path is new — but it would have shipped the defect back into a client that had just been taught not to have it. `Slot` now carries `motionReaches`, asked once at open off the kind that pad DECLARED, in the same shape `ExternalPad` already used. Per pad, not per session: under Automatic the handshake carries the active pad's kind, so a couch with an X-Box pad on slot 0 and a DualSense on slot 1 must not have slot 1's working gyro suppressed by slot 0's answer. The notice moved to where the truth is known. `openSlot` knows only what kind a pad declared, not whether it physically has a gyro — that is discovered later, when `PadSensors` finds a gyroscope and calls `setDeviceHasSensorMotion`. Raising it there is the only placement that both tells a player whose gyro is being dropped and stays silent for the pads that never had one. Also unified the last duplicate scale in the module. G10 hoisted the wire units into `Gamepad` and pointed `DeviceGyro` at them, but `DsDevice` kept its own `20L` / `10000L` — and `Gamepad`'s new comment claims every sender goes through one place, which was not yet true. Two copies of a unit constant in one module is precisely the defect this program opened with (a DualShock 4 blob 40× hot because a second copy had drifted), so the claim and the code now agree. `val` rather than `const val` only because widening to Long is not a constant expression; Long is deliberate, since the calibration arithmetic overflows an Int before it divides. Proven non-vacuous rather than assumed: changing `Gamepad.MOTION_GYRO_LSB_PER_DEG_S` from 20 to 16 now fails four named cases across three classes — `DsDeviceTest.calibrationRescalesRawCountsOntoTheWireUnits`, `.theHostsOwnBlobIsAPassthrough`, `.parseStateAppliesTheCalibration` and `DeviceGyroTest.wireUnitConstants`. Before this change `DsDevice` would not have noticed. The gate itself has no test, for the reason the surrounding code already documents: `GamepadRouter` needs Android plus a live JNI handle, there is no Robolectric in this module, and a mock would test the mock. It is argued at the call sites instead. Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest`, `:app:compileDebugKotlin`, `:app:testDebugUnitTest` — kit 75 / app 67, 0 failures, counts read out of the JUnit XML. The merge reconciles: 62 on this branch, plus 6 from main's DeviceGyroTest, plus G10's 7. |
||
|
|
e81ab1ff2b | Merge branch 'worktree-agent-a6f74e8ea7824fb56' into worktree-gyro-p0-correctness | ||
|
|
8f1081719f |
feat(client/android): a Bluetooth controller's gyro stops going nowhere
Android had two motion sources and both of them are USB claims. DsCapture takes a Sony pad's HID interface away from the kernel; Sc2Capture does the same for a Steam Controller 2. Everything else — a DualSense, a DualShock 4, a Switch Pro, an 8BitDo, paired over Bluetooth — arrives as an ordinary InputDevice. Its buttons worked, its sticks worked, and its gyro was dead, silently, with no log line and nothing in the UI to suggest the pad had a sensor at all. That is not one controller, it is the whole class of controllers people actually pair to a phone. The platform has had the answer since Android 12: InputDevice.getSensorManager hands back a SensorManager scoped to that one controller, carrying its TYPE_GYROSCOPE and TYPE_ACCELEROMETER. PadSensors registers a listener per forwarded pad that has a gyroscope and sends the samples on that pad's wire index. Below API 31 it registers nothing and the pads behave exactly as they did. It is built on DeviceGyro's shape, because the phone mirror had already paid for these lessons. One dedicated HandlerThread, never the main one. Batching off (maxReportLatencyUs = 0) — batching would trade away precisely the latency gyro aim exists to avoid. 200 Hz requested, which is also the ceiling the framework grants an app without HIGH_SAMPLING_RATE_SENSORS, so asking for more would only be capped. And a feed that lets go of a pad still alive parks its rotation at zero first: the host holds motion as state and re-emits it in every virtual-pad report, so an angular velocity left behind is a pad that rotates forever. Two writers on one pad's motion is the failure this program has spent the day unpicking, so the coordination is explicit in three places. A USB capture wins: DsCapture.startUsb already calls releaseDevice at claim time, that closes the slot, and the close now also takes the sensor listeners off — the claim makes the InputDevice vanish anyway, but going through the explicit teardown is what makes the ordering deterministic instead of a race against the platform's own removal callback. The phone-gyro mirror stands down: registering flips a bit the router reports through padHasOwnMotion, which DeviceGyro re-reads on every sample and answers with its own zero park. And a pad with an accelerometer but no gyroscope is deliberately NOT taken — it could only send gravity while pinning rotation at zero, on a pad the mirror is otherwise entitled to speak for, which is the same fight in a quieter costume. The wire units are measured fact (punktfunk_core::input::gamepad: 20 LSB/deg·s, 10000 LSB/g), and they now live in exactly one place on this client: Gamepad.motionGyroWire / motionAccelWire, which DeviceGyro was hand-inlining a second copy of. The gyro program's first finding was a client sending 40x hot because a second copy of a number had drifted, and the merge that followed found a sender nobody remembered to correct. One function, both callers. THE AXIS FRAME ON THIS PATH IS NOT VERIFIED, and the mapping is deliberately straight through rather than guessed at. What is known: the wire is a unit passthrough into a virtual DualSense report, and that report's frame was measured over raw HID on 2026-08-07 as (Right, Up, Backward-toward-the-player) carrying (pitch, yaw, roll), right-handed — which is why the USB path forwards the pad's own order un-remapped and is correct to. Android documents its sensor frame for a handheld device as +x right, +y up, +z out of the face, the same frame once "the face" is read as the one the player looks at. So straight through is what the documentation implies. What nobody has done is put a Bluetooth DualSense in front of the platform sensor framework and compare — those numbers come through a HID driver and InputFlinger's sensor mapper, either of which could permute or negate without saying so. A plausible-looking wrong remap is exactly the bug this program keeps finding, so the code says unverified and names the measurement that settles it, and each feed logs its first converted sample so the cheapest half of that measurement — which slot gravity lands on with the pad flat and still — costs a logcat line. PadSensorsTest pins the scale, the clamp, the rounding and the straight-through order, mutation-checked four ways: 20 to 16 fails gyroScaleFromRadiansPerSecond and straightThroughFrame, reversing the axis order fails straightThroughFrame, truncating instead of rounding fails roundsToNearestNotTowardZero, and negating the accel fails restingPadIsTheHostNeutral. Its frame expectations are written to change together with any remap that lands, not to be edited around one. GamepadRouter needs Android and a live JNI handle and there is no Robolectric here, so its half is argued in comments beside the code, as DsCapture's claim ordering already is. Gates: kit 65 tests (58 before, plus 7), app 67 unchanged, 0 failures, read out of the JUnit XML rather than off a green build. |
||
|
|
979ed8f426 |
docs(clients/pads): say that an X-Box virtual pad has nowhere to put motion
G17's motion half. The docs described what the CLIENT sends and stopped there, which
made a promise the host does not always keep.
The support matrix said a desktop client forwards motion from any pad SDL exposes a gyro
on "and the host injects it into the matching virtual pad". The first clause is true; the
second is only true when the virtual pad has a motion plane. The X-Box 360 and One
backends do not — no gyro in their HID contract — so the host parses every sample and
discards it. That is where *Automatic* lands anything it does not recognise as Sony or
Valve, an 8BitDo with a perfectly good gyro included, and where a Switch Pro lands on a
Windows host with no `hid-nintendo` backend to fold it into.
A reader following the old text would conclude their gyro was broken. The failure has no
other symptom: motion just does nothing.
So both pages now say what to do about it — pick a DualSense-class type — and the
client-settings page says it where the choice is actually made, next to the degrade
paragraph that explains why a session ends up on an X-Box pad in the first place.
The Deck's Steam-Input requirement moves out of Decky's settings blurb, which is the one
place a Deck user streaming FROM the Deck would never look. With Steam Input on, Steam
hands the app its own virtual X-Box pad, so no controller-type choice can help: there is
no gyro on the pad the client can see.
The picker help text now mentions motion on GTK and Android, which is where it was
missing — Windows already said it and Apple says it in its own words. One sentence, the
same sentence, so the four clients answer the question the same way.
This is the doc side of the on-screen notice that shipped earlier in this branch. The two
exist for the same reason and now agree: the client says it when it detects the case, the
docs say it when someone goes looking.
Not covered: the preset COUNTS in note 1 ("Android and the console home offer six …
Windows and Apple offer five") are still unverified against the four pickers, and the
Apple picker's missing Steam Deck entry is a code gap rather than a doc one. Both are
noted in the plan and left for their own change rather than guessed at here.
Gate: Linux CI image fmt + `clippy --locked --all-targets -D warnings` on
punktfunk-client-linux (the GTK string is compiled) plus the core crates and their tests;
Android `:app:compileDebugKotlin` + `:app:testDebugUnitTest`. Green.
|
||
|
|
5a4305c072 |
merge: bring current main into the gyro correctness branch
main moved ~60 commits while this branch was in progress, and one of them matters here: PR #88 (the phone-gyro mirror) landed, touching the same motion path. One conflicted file, `GamepadCapture.swift`, in three places — all of them the two changes meeting rather than disagreeing: - **Slot fields.** #88 added `motionSent` + `lastAccel` for its flush-parks-motion fix; this branch removed `lastMotionNs` with the 4 ms drop-throttle. Kept both decisions: the parking state stays, the throttle field goes. - **forwardMotion's head.** #88 added the mirror stand-down (`pad 0` yields while the phone speaks for it); this branch deleted the throttle guard. Kept the stand-down, dropped the guard. - **The send.** This branch converts into the DualSense report frame; #88 records what went out so `flush` can replay it beside a zero gyro. Both, with the recording placed AFTER the conversion — `flush` replays `lastAccel`, so it has to be the vector that actually went on the wire, or a still pad's gravity gets parked in the wrong axis. The two features compose exactly, which is worth stating because it is not luck: this branch gates motion capture on `hasRotationRate`, and #88 engages the phone mirror when `hasRotationRate != true`. They are complements — a pad either drives its own gyro or the phone mirrors for it, never both and never neither. Everything else auto-merged. Note `DeviceGyroRemapTests` is `#if os(iOS)`, so the macOS suite reports the same 215 as before the merge rather than gaining #88's six — checked, not assumed. Gates re-run against the merged tree rather than trusting either side's: Linux fmt + build + `clippy --locked --all-targets -D warnings` + punktfunk-core and pf-inject suites; Apple 215 tests and the iOS-triple typecheck; Android kit + app compile and tests. All green. |
||
|
|
7cab7ae6bc |
feat(client/android): say when a captured pad's gyro can't reach the session
G8's Android half, and the last of the three clients. Same failure as the other two: a controller with a gyro, in a session whose virtual pad has no motion plane, does nothing when tilted — silently, with no way from the couch to tell that apart from a broken sensor. The fix is the Controller type setting, so the notice names it. Android read neither the requested nor the resolved backend, so this needed a plumb. What it did NOT need was a third copy of the rule. `nativePadMotionReaches` takes the kind a pad declared and answers off `pad_motion_reaches` in punktfunk-core, where the argument and the tests already live. The rule is subtler than it looks — the host builds each pad from its OWN declaration and folds what it cannot build, so neither the declaration nor the session echo answers it alone — and every way of getting it wrong is silent. A Kotlin transcription would have been a third thing to keep in step with the host, which is exactly how the SDL half got it wrong the first time. Asked once per pad, at claim, in `openExternal` — where the pad's kind is already being declared to the host — and the answer held for the pad's lifetime on the `ExternalPad`. Not per sample: this runs at a DualSense's full report rate. `hasGyro` gates only the NOTICE, and defaults to false. `DsCapture` passes true — every pad it captures is a Sony one whose IMU is a headline feature, forwarded on the rich plane. `Sc2Capture` keeps the default, because the Steam Controller 2's motion rides inside the opaque passthrough report that `hidReport` carries, which nothing here may second-guess: warning about motion for a pad that never calls `motion()` would be a notice about a feature the player never lost. The suppression itself is on `motion()` regardless, where it costs a dead pad nothing and stops a live one paying to send samples the host will decode and discard. The notice sits at the BOTTOM of the stream overlay, unlike the mic-chord confirmation at the top. The two can coincide — a pad is claimed at roughly the moment someone might be muting — and one landing on the other would cost the user both. It holds 6 s rather than the mic chord's 1.6: that one confirms something the user just did, this one explains something they did not, in a sentence they have to read. Nulled at teardown beside `onExitArmed`/`onMicChord`, for the same reason those are — a slot closing during release must not poke Compose state on the way out. Not covered by tests, and this is a limit of the module rather than a choice: `GamepadRouter` needs Android plus a live JNI handle, there is no Robolectric here, and the predicate it defers to is pure Rust that already has its table. So the parts that carry the reasoning are argued in comments, as `DsCapture`'s claim/teardown ordering already is. What IS mechanically verified is the piece that a compiler cannot catch and a device would fail on: the JNI symbol `Java_io_unom_punktfunk_kit_NativeBridge_nativePadMotionReaches` is present and global in the built arm64-v8a `.so`, so the `external fun` resolves rather than throwing `UnsatisfiedLinkError` at the first pad. Gate: `:kit:compileDebugKotlin`, `:kit:testDebugUnitTest` (62 cases, 0 failed, read out of the JUnit XML rather than inferred from a green build — unchanged from this branch's previous count), `:app:compileDebugKotlin` and `:app:testDebugUnitTest` (67 cases, 0 failed), with `:kit:cargoNdkRelease` rebuilding the JNI crate clean across all three ABIs, plus `cargo fmt --check` on it. On-glass verification is owed on the rig the earlier legs used, and is worth doing as one pass with the two already owed there. |
||
|
|
8e8d30202c |
fix(client/android): a Sony pad's buttons no longer wait on its calibration
Supersedes the parse gate in |
||
|
|
26b0819f5c |
fix(client/android): plugging in a Sony pad could hitch the interface
Supersedes the synchronous calibration read |